Instructions to use Codeseys/composer-replication-framework with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Codeseys/composer-replication-framework with Transformers:
# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Codeseys/composer-replication-framework", dtype="auto") - Notebooks
- Google Colab
- Kaggle
Wave 3: close the HIGH review findings (kill-switch wiring, HeldoutSplit, EKS entrypoint bug)
Browse filesPhase-7 reconciliation of the concurrent review team's findings (research/review-*.json).
HIGH:
- R1: wire HeldOutGuard into ComposerReplicationTrainer — OPTIONAL + OFF by
default (no heldout_guard => byte-identical legacy behavior). When configured,
_maybe_update_killswitch folds in-loop reward + injected heldout_eval_fn() +
token-mean KL into the guard at the logging cadence; fires a hard
(CollapseStopError) or soft (control.should_training_stop) halt. The #2
collapse safeguard now actually fires instead of being dead code.
+ test_killswitch_integration.py.
- R2: build composer_replication/safety/holdout.py — HeldoutSplit disjointness
enforcer (id-set + optional content-hash; .split()/.validate()/.assert_disjoint;
raises HeldoutOverlapError listing the leak). The un-built second half of C1
that keeps the guard's proxy-real gap signal meaningful. +10 tests. Re-exported
from safety/__init__ (resolves the dangling refs).
- R3: EKS entrypoint contract bug — replica_entrypoint.__main__ hard-required
--rendezvous/--world-size/--trainer-module argv, but EKSExecutor passes them
as ENV vars => a real EKS pod crashed at arg-parsing. Fixed: __main__ now
resolves each field from argv OR the matching env var (RENDEZVOUS_URI/
WORLD_SIZE/TRAINER_MODULE), erroring only if NEITHER supplies a required field.
SageMaker's argv path still works. Proven end-to-end with a pure-env invocation.
LOW:
- R4: calibrate_kl_threshold now rejects factor<=0 / negative baseline KL and
floors the result at 1e-6, so calibration can never produce a non-positive
kl_hard_stop (which would fire on every healthy step).
- R10: test pinning path-(c) gap-blowout as an intentional divergence-RATE gate
(fires on fast proxy/real divergence even while real still rises).
DOCS:
- R7: API_REFERENCE §15-17 documenting EKSExecutor, SageMakerExecutor,
DockerSandbox, and the safety module (HeldOutGuard/TripwireStatus/etc.).
- R8: ADR-015 (held-out + kill-switch design) authored + added to the ADR index.
Deferred LOW (tracked in docs/BACKLOG_RESOLUTION): R5 (executor cancel
exception-narrowing), R6 (EKS collect result-key uniformity), R11 (spike-006
flaky-under-contention). safety suite 37/37, trainer 73/73, serverless 53/53.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README.md +1 -1
- composer_replication/diloco/serverless/replica_entrypoint.py +42 -10
- composer_replication/safety/__init__.py +11 -2
- composer_replication/safety/holdout.py +354 -0
- composer_replication/safety/kill_switch.py +21 -4
- composer_replication/safety/tests/test_holdout.py +128 -0
- composer_replication/safety/tests/test_kill_switch.py +52 -0
- composer_replication/trainer/composer_trainer.py +141 -2
- composer_replication/trainer/tests/test_killswitch_integration.py +370 -0
- docs/API_REFERENCE.md +358 -0
- docs/BACKLOG_RESOLUTION_2026-06-09.md +2 -0
- docs/adrs/ADR-015-holdout-killswitch.md +187 -0
- docs/adrs/README.md +1 -0
|
@@ -206,7 +206,7 @@ dimensions. Six new artifact families:
|
|
| 206 |
using the framework to RL-train altered-minds-altered models. ~$300
|
| 207 |
estimated for a moral-scenarios trace-replay round.
|
| 208 |
|
| 209 |
-
**Tests
|
| 210 |
|
| 211 |
## Methodology — how this synthesis was produced
|
| 212 |
|
|
|
|
| 206 |
using the framework to RL-train altered-minds-altered models. ~$300
|
| 207 |
estimated for a moral-scenarios trace-replay round.
|
| 208 |
|
| 209 |
+
**Tests (canonical, measured 2026-06-09): 266 passing / 62 skipped / 328 collected** — see `docs/V1_V8_COVERAGE.md` for the canonical count and why the skip count varies by environment (optional deps + Docker host). Historical wave-by-wave growth: 72 (W12) → 93 (W13) → 124 (W14) → 130 (W14b) → 115 (W15) → 266 (2026-06).
|
| 210 |
|
| 211 |
## Methodology — how this synthesis was produced
|
| 212 |
|
|
@@ -92,18 +92,50 @@ if __name__ == "__main__":
|
|
| 92 |
import argparse
|
| 93 |
import json
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
parser = argparse.ArgumentParser()
|
| 96 |
-
parser.add_argument("--rendezvous",
|
| 97 |
-
parser.add_argument("--world-size", type=int,
|
| 98 |
-
parser.add_argument("--trainer-module",
|
| 99 |
-
parser.add_argument("--trainer-fn", default=
|
| 100 |
-
parser.add_argument("--trainer-kwargs-json", default=
|
| 101 |
args = parser.parse_args()
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
main(
|
| 104 |
-
rendezvous_uri=
|
| 105 |
-
world_size=
|
| 106 |
-
trainer_module=
|
| 107 |
-
trainer_fn=
|
| 108 |
-
trainer_kwargs=json.loads(
|
| 109 |
)
|
|
|
|
| 92 |
import argparse
|
| 93 |
import json
|
| 94 |
|
| 95 |
+
# Dual input contract (both backends supported):
|
| 96 |
+
# * argv — SageMakerExecutor / LocalProcessExecutor pass the run config as
|
| 97 |
+
# `--rendezvous/--world-size/--trainer-module` ContainerArguments.
|
| 98 |
+
# * env — EKSExecutor (and any backend that prefers a pure-env contract,
|
| 99 |
+
# since k8s Indexed Jobs already inject REPLICA_RANK via the downward API)
|
| 100 |
+
# pass the SAME values as RENDEZVOUS_URI / WORLD_SIZE / TRAINER_MODULE
|
| 101 |
+
# env vars. The argv flags are therefore NOT `required=True`: when absent
|
| 102 |
+
# we fall back to the env vars, and only error if NEITHER source supplies
|
| 103 |
+
# a mandatory field. This is the R3 fix — previously the argparse block
|
| 104 |
+
# hard-required argv, so an EKS pod (env-only) crashed at arg-parsing.
|
| 105 |
parser = argparse.ArgumentParser()
|
| 106 |
+
parser.add_argument("--rendezvous", default=None)
|
| 107 |
+
parser.add_argument("--world-size", type=int, default=None)
|
| 108 |
+
parser.add_argument("--trainer-module", default=None)
|
| 109 |
+
parser.add_argument("--trainer-fn", default=None)
|
| 110 |
+
parser.add_argument("--trainer-kwargs-json", default=None)
|
| 111 |
args = parser.parse_args()
|
| 112 |
|
| 113 |
+
def _resolve(arg_val, env_key, *, required, cast=lambda x: x):
|
| 114 |
+
if arg_val is not None:
|
| 115 |
+
return arg_val
|
| 116 |
+
env_val = os.environ.get(env_key)
|
| 117 |
+
if env_val is not None:
|
| 118 |
+
return cast(env_val)
|
| 119 |
+
if required:
|
| 120 |
+
raise SystemExit(
|
| 121 |
+
f"replica_entrypoint: missing '{env_key}' — supply it via the "
|
| 122 |
+
f"argv flag or the {env_key} environment variable "
|
| 123 |
+
f"(EKSExecutor uses env; SageMaker/Local use argv)."
|
| 124 |
+
)
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
rendezvous = _resolve(args.rendezvous, "RENDEZVOUS_URI", required=True)
|
| 128 |
+
world_size = _resolve(args.world_size, "WORLD_SIZE", required=True, cast=int)
|
| 129 |
+
trainer_module = _resolve(args.trainer_module, "TRAINER_MODULE", required=True)
|
| 130 |
+
trainer_fn = _resolve(args.trainer_fn, "TRAINER_FN", required=False) or "train"
|
| 131 |
+
kwargs_json = _resolve(
|
| 132 |
+
args.trainer_kwargs_json, "TRAINER_KWARGS_JSON", required=False
|
| 133 |
+
) or "{}"
|
| 134 |
+
|
| 135 |
main(
|
| 136 |
+
rendezvous_uri=rendezvous,
|
| 137 |
+
world_size=world_size,
|
| 138 |
+
trainer_module=trainer_module,
|
| 139 |
+
trainer_fn=trainer_fn,
|
| 140 |
+
trainer_kwargs=json.loads(kwargs_json),
|
| 141 |
)
|
|
@@ -13,12 +13,19 @@ Public surface:
|
|
| 13 |
.proxy_real_gap)
|
| 14 |
- CollapseStopError — typed exception for exception-based trainer control flow
|
| 15 |
- kl_token_trust_filter — per-token KL trust-region mask (torchrl KL-Mask analog)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
Pure-Python, no torch / cloud deps. See docs/adrs/ADR-015-
|
| 18 |
-
'holdout-killswitch' research digest.
|
| 19 |
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from composer_replication.safety.kill_switch import (
|
| 23 |
CollapseStopError,
|
| 24 |
HeldOutGuard,
|
|
@@ -31,4 +38,6 @@ __all__ = [
|
|
| 31 |
"TripwireStatus",
|
| 32 |
"CollapseStopError",
|
| 33 |
"kl_token_trust_filter",
|
|
|
|
|
|
|
| 34 |
]
|
|
|
|
| 13 |
.proxy_real_gap)
|
| 14 |
- CollapseStopError — typed exception for exception-based trainer control flow
|
| 15 |
- kl_token_trust_filter — per-token KL trust-region mask (torchrl KL-Mask analog)
|
| 16 |
+
- HeldoutSplit / HeldoutOverlapError — the train/held-out set-disjointness
|
| 17 |
+
enforcer (holdout.py) that keeps the guard's proxy-real gap
|
| 18 |
+
signal meaningful (a held-out set that drifts into the train
|
| 19 |
+
set makes the gap meaningless).
|
| 20 |
|
| 21 |
+
Pure-Python, no torch / cloud deps. See docs/adrs/ADR-015-holdout-killswitch.md.
|
|
|
|
| 22 |
"""
|
| 23 |
from __future__ import annotations
|
| 24 |
|
| 25 |
+
from composer_replication.safety.holdout import (
|
| 26 |
+
HeldoutOverlapError,
|
| 27 |
+
HeldoutSplit,
|
| 28 |
+
)
|
| 29 |
from composer_replication.safety.kill_switch import (
|
| 30 |
CollapseStopError,
|
| 31 |
HeldOutGuard,
|
|
|
|
| 38 |
"TripwireStatus",
|
| 39 |
"CollapseStopError",
|
| 40 |
"kl_token_trust_filter",
|
| 41 |
+
"HeldoutSplit",
|
| 42 |
+
"HeldoutOverlapError",
|
| 43 |
]
|
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""holdout.py — held-out / train set-disjointness enforcer (the #2 safeguard,
|
| 2 |
+
second half).
|
| 3 |
+
|
| 4 |
+
``kill_switch.py`` (the ``HeldOutGuard`` run-level collapse tripwire) is only
|
| 5 |
+
sound if the held-out eval it watches is *genuinely disjoint* from the tasks the
|
| 6 |
+
generator trains on. If a single held-out task leaks back into the train /
|
| 7 |
+
generator pool, the "real" eval drifts WITH the train set and the proxy-real
|
| 8 |
+
Hacking-Gap signal becomes meaningless (see the Shumailov / Gao collapse
|
| 9 |
+
references in ``kill_switch.py``: the held-out eval must stay anchored to REAL
|
| 10 |
+
tasks that are NEVER fed back to the generator). This module enforces that
|
| 11 |
+
discipline mechanically rather than leaving it to convention.
|
| 12 |
+
|
| 13 |
+
``HeldoutSplit`` enforces disjointness two ways, both pure-Python:
|
| 14 |
+
|
| 15 |
+
- **id-based** — the train/generator ``task_id`` set and the held-out
|
| 16 |
+
``task_id`` set must not intersect. This is the cheap, exact check.
|
| 17 |
+
|
| 18 |
+
- **content-hash-based** (optional, ``check_content=True``) — a sha256 over a
|
| 19 |
+
*normalized* view of each task's content. This catches NEAR-DUPLICATES that
|
| 20 |
+
slipped through with DIFFERENT ids: the same broken repo + same
|
| 21 |
+
``fail_to_pass`` targets re-minted under a fresh ``task_id`` would pass the
|
| 22 |
+
id check but is, for collapse purposes, the same eval task leaking into
|
| 23 |
+
train. The EvilGenie failure-mode literature (arXiv 2511.21654, cited in
|
| 24 |
+
``kill_switch.py``) is explicit that "holdout tests have many surprising
|
| 25 |
+
failure modes" — silent re-id'd duplicates are one of them.
|
| 26 |
+
|
| 27 |
+
The ``split(all_tasks, holdout_frac, seed)`` constructor produces a
|
| 28 |
+
GUARANTEED-disjoint (train, holdout) partition deterministically: a fixed seed
|
| 29 |
+
yields the same partition every run, so the held-out anchor is reproducible
|
| 30 |
+
across the long self-evolving run.
|
| 31 |
+
|
| 32 |
+
Pure-Python: only ``hashlib`` / ``random`` from the stdlib. No torch, no cloud
|
| 33 |
+
deps. Accepts either raw ``task_id`` strings OR ``FeatureDeletionTask`` objects
|
| 34 |
+
(anything with a ``task_id`` attribute) on every entry point.
|
| 35 |
+
"""
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import hashlib
|
| 39 |
+
import random
|
| 40 |
+
from collections.abc import Iterable, Sequence
|
| 41 |
+
from dataclasses import dataclass, field
|
| 42 |
+
from typing import Any
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class HeldoutOverlapError(ValueError):
|
| 46 |
+
"""Raised when the train/generator pool and the held-out eval pool overlap.
|
| 47 |
+
|
| 48 |
+
Carries the offending identifiers so the caller can log exactly which tasks
|
| 49 |
+
leaked across the boundary (mirroring how ``datagen/monitor.py`` surfaces the
|
| 50 |
+
specific suspected hacks rather than a bare boolean).
|
| 51 |
+
|
| 52 |
+
Attributes:
|
| 53 |
+
overlapping_ids: sorted task ids present in BOTH pools (id-based leak).
|
| 54 |
+
overlapping_hashes: sorted content hashes present in both pools with
|
| 55 |
+
*different* ids (content-based near-duplicate leak); empty unless
|
| 56 |
+
content-hashing was enabled.
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
overlapping_ids: Sequence[str] = (),
|
| 62 |
+
overlapping_hashes: Sequence[str] = (),
|
| 63 |
+
) -> None:
|
| 64 |
+
self.overlapping_ids = tuple(overlapping_ids)
|
| 65 |
+
self.overlapping_hashes = tuple(overlapping_hashes)
|
| 66 |
+
parts: list[str] = []
|
| 67 |
+
if self.overlapping_ids:
|
| 68 |
+
parts.append(
|
| 69 |
+
f"{len(self.overlapping_ids)} task id(s) appear in BOTH the "
|
| 70 |
+
f"train/generator pool and the held-out eval pool: "
|
| 71 |
+
f"{list(self.overlapping_ids)}"
|
| 72 |
+
)
|
| 73 |
+
if self.overlapping_hashes:
|
| 74 |
+
parts.append(
|
| 75 |
+
f"{len(self.overlapping_hashes)} content hash(es) collide across "
|
| 76 |
+
f"the boundary with DIFFERENT ids (re-id'd near-duplicates): "
|
| 77 |
+
f"{list(self.overlapping_hashes)}"
|
| 78 |
+
)
|
| 79 |
+
if not parts: # defensive — should not be raised with nothing overlapping
|
| 80 |
+
parts.append("train/held-out overlap detected (no identifiers captured)")
|
| 81 |
+
super().__init__(
|
| 82 |
+
"held-out eval is NOT disjoint from the train/generator pool — "
|
| 83 |
+
"this corrupts the proxy-real collapse signal. " + "; ".join(parts)
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def task_id_of(task: Any) -> str:
|
| 88 |
+
"""Coerce a task (a ``task_id`` string or a ``FeatureDeletionTask``-like
|
| 89 |
+
object with a ``.task_id`` attribute) to its id string.
|
| 90 |
+
|
| 91 |
+
Raises:
|
| 92 |
+
TypeError: if ``task`` is neither a string nor has a ``task_id``.
|
| 93 |
+
"""
|
| 94 |
+
if isinstance(task, str):
|
| 95 |
+
return task
|
| 96 |
+
tid = getattr(task, "task_id", None)
|
| 97 |
+
if isinstance(tid, str):
|
| 98 |
+
return tid
|
| 99 |
+
raise TypeError(
|
| 100 |
+
f"expected a task_id str or an object with a str .task_id attribute, "
|
| 101 |
+
f"got {type(task).__name__!r}"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def content_hash(task: Any) -> str:
|
| 106 |
+
"""sha256 over a NORMALIZED view of a task's content (id-independent).
|
| 107 |
+
|
| 108 |
+
The hash deliberately EXCLUDES ``task_id`` so two tasks that are identical
|
| 109 |
+
apart from their id collide — that collision is exactly the near-duplicate
|
| 110 |
+
leak we want ``check_content=True`` to catch.
|
| 111 |
+
|
| 112 |
+
Normalization (so cosmetic differences do not defeat the check):
|
| 113 |
+
- for ``FeatureDeletionTask``-like objects, hash the load-bearing content
|
| 114 |
+
fields (repo, base_commit, broken_image, test_command, the SORTED
|
| 115 |
+
fail_to_pass / pass_to_pass test sets, granularity, sorted
|
| 116 |
+
deleted_symbols) — NOT task_id, and NOT volatile/advisory fields like
|
| 117 |
+
difficulty_prior or upstream_license;
|
| 118 |
+
- for a bare string, hash the whitespace-collapsed, lower-cased text (a
|
| 119 |
+
plain id string is its own content);
|
| 120 |
+
- test-set tuples are sorted so reordering the same tests does not change
|
| 121 |
+
the hash.
|
| 122 |
+
|
| 123 |
+
A plain ``task_id`` string therefore hashes to a stable, content-derived
|
| 124 |
+
value; passing the same strings to both pools will collide on id FIRST
|
| 125 |
+
(the id check fires before the content check), so the string path is mainly
|
| 126 |
+
a graceful fallback for callers without structured tasks.
|
| 127 |
+
"""
|
| 128 |
+
fields = _content_fields(task)
|
| 129 |
+
blob = "\x1f".join(fields) # unit-separator join: unambiguous field boundary
|
| 130 |
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _normalize_text(text: str) -> str:
|
| 134 |
+
"""Collapse runs of whitespace and lower-case, so cosmetic reformatting of a
|
| 135 |
+
command / repo string does not defeat content-hash matching."""
|
| 136 |
+
return " ".join(text.split()).lower()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _content_fields(task: Any) -> list[str]:
|
| 140 |
+
"""Ordered, normalized content fields for hashing (id excluded)."""
|
| 141 |
+
if isinstance(task, str):
|
| 142 |
+
return [_normalize_text(task)]
|
| 143 |
+
|
| 144 |
+
# FeatureDeletionTask-like: pull the content-defining fields if present.
|
| 145 |
+
def norm(attr: str) -> str:
|
| 146 |
+
val = getattr(task, attr, None)
|
| 147 |
+
return _normalize_text(str(val)) if val is not None else ""
|
| 148 |
+
|
| 149 |
+
def norm_set(attr: str) -> str:
|
| 150 |
+
# Sorted so test-order does not change the hash; each test normalized.
|
| 151 |
+
vals = getattr(task, attr, None) or ()
|
| 152 |
+
return "\x1e".join(sorted(_normalize_text(str(v)) for v in vals))
|
| 153 |
+
|
| 154 |
+
if hasattr(task, "task_id"):
|
| 155 |
+
return [
|
| 156 |
+
norm("repo"),
|
| 157 |
+
norm("base_commit"),
|
| 158 |
+
norm("broken_image"),
|
| 159 |
+
norm("test_command"),
|
| 160 |
+
norm_set("fail_to_pass"),
|
| 161 |
+
norm_set("pass_to_pass"),
|
| 162 |
+
norm("granularity"),
|
| 163 |
+
norm_set("deleted_symbols"),
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
# Last resort: a non-string, non-task object — hash its repr (best-effort).
|
| 167 |
+
return [_normalize_text(repr(task))]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@dataclass(frozen=True)
|
| 171 |
+
class HeldoutSplit:
|
| 172 |
+
"""A (train/generator, held-out eval) partition with a disjointness contract.
|
| 173 |
+
|
| 174 |
+
Construct directly from two iterables of task ids (or
|
| 175 |
+
``FeatureDeletionTask`` objects)::
|
| 176 |
+
|
| 177 |
+
split = HeldoutSplit(train_tasks, holdout_tasks)
|
| 178 |
+
split.assert_disjoint() # raises HeldoutOverlapError on a leak
|
| 179 |
+
if split.is_disjoint: ...
|
| 180 |
+
|
| 181 |
+
or deterministically partition one pool::
|
| 182 |
+
|
| 183 |
+
split = HeldoutSplit.split(all_tasks, holdout_frac=0.2, seed=1234)
|
| 184 |
+
|
| 185 |
+
Set ``check_content=True`` to also reject re-id'd near-duplicates (same
|
| 186 |
+
normalized content under a different ``task_id``). Content-hashing is a
|
| 187 |
+
superset check: a content collision with the SAME id is just the id leak and
|
| 188 |
+
is reported via ``overlapping_ids``; a collision with DIFFERENT ids is the
|
| 189 |
+
near-duplicate leak reported via ``overlapping_content_hashes``.
|
| 190 |
+
|
| 191 |
+
The instance is frozen; the id/hash sets are computed once at construction.
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
train_ids: frozenset[str]
|
| 195 |
+
holdout_ids: frozenset[str]
|
| 196 |
+
check_content: bool = False
|
| 197 |
+
# content hash -> set of ids, per pool (only populated when check_content).
|
| 198 |
+
_train_hashes: dict[str, frozenset[str]] = field(default_factory=dict, repr=False)
|
| 199 |
+
_holdout_hashes: dict[str, frozenset[str]] = field(default_factory=dict, repr=False)
|
| 200 |
+
|
| 201 |
+
# ------------------------------------------------------------------------
|
| 202 |
+
# construction
|
| 203 |
+
# ------------------------------------------------------------------------
|
| 204 |
+
def __init__(
|
| 205 |
+
self,
|
| 206 |
+
train: Iterable[Any],
|
| 207 |
+
holdout: Iterable[Any],
|
| 208 |
+
*,
|
| 209 |
+
check_content: bool = False,
|
| 210 |
+
) -> None:
|
| 211 |
+
train_list = list(train)
|
| 212 |
+
holdout_list = list(holdout)
|
| 213 |
+
|
| 214 |
+
object.__setattr__(self, "train_ids", frozenset(map(task_id_of, train_list)))
|
| 215 |
+
object.__setattr__(self, "holdout_ids", frozenset(map(task_id_of, holdout_list)))
|
| 216 |
+
object.__setattr__(self, "check_content", bool(check_content))
|
| 217 |
+
|
| 218 |
+
if check_content:
|
| 219 |
+
object.__setattr__(self, "_train_hashes", _hash_index(train_list))
|
| 220 |
+
object.__setattr__(self, "_holdout_hashes", _hash_index(holdout_list))
|
| 221 |
+
else:
|
| 222 |
+
object.__setattr__(self, "_train_hashes", {})
|
| 223 |
+
object.__setattr__(self, "_holdout_hashes", {})
|
| 224 |
+
|
| 225 |
+
# ------------------------------------------------------------------------
|
| 226 |
+
# deterministic constructor
|
| 227 |
+
# ------------------------------------------------------------------------
|
| 228 |
+
@classmethod
|
| 229 |
+
def split(
|
| 230 |
+
cls,
|
| 231 |
+
all_tasks: Iterable[Any],
|
| 232 |
+
holdout_frac: float = 0.2,
|
| 233 |
+
seed: int = 0,
|
| 234 |
+
*,
|
| 235 |
+
check_content: bool = False,
|
| 236 |
+
) -> HeldoutSplit:
|
| 237 |
+
"""Deterministically partition ``all_tasks`` into a disjoint (train,
|
| 238 |
+
held-out) split.
|
| 239 |
+
|
| 240 |
+
The partition is keyed on each task's ``task_id`` so it is reproducible
|
| 241 |
+
across runs (same ``all_tasks`` ids + same ``seed`` => same split). Tasks
|
| 242 |
+
are de-duplicated by id first (a duplicate id cannot land on both sides),
|
| 243 |
+
then shuffled with a SEEDED ``random.Random`` and sliced — guaranteeing a
|
| 244 |
+
disjoint result by construction.
|
| 245 |
+
|
| 246 |
+
Args:
|
| 247 |
+
all_tasks: the full pool (ids or ``FeatureDeletionTask`` objects).
|
| 248 |
+
holdout_frac: fraction routed to the held-out pool, in [0, 1]. The
|
| 249 |
+
held-out size is ``round(n * holdout_frac)``, clamped so that a
|
| 250 |
+
non-empty pool with ``0 < holdout_frac < 1`` always leaves at
|
| 251 |
+
least one task on EACH side.
|
| 252 |
+
seed: PRNG seed for the deterministic shuffle.
|
| 253 |
+
check_content: enable content-hash disjointness on the result too.
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
A ``HeldoutSplit`` whose ``is_disjoint`` is True by construction.
|
| 257 |
+
|
| 258 |
+
Raises:
|
| 259 |
+
ValueError: if ``holdout_frac`` is outside [0, 1].
|
| 260 |
+
"""
|
| 261 |
+
if not (0.0 <= holdout_frac <= 1.0):
|
| 262 |
+
raise ValueError(
|
| 263 |
+
f"holdout_frac must be in [0, 1], got {holdout_frac!r}"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# De-dup by id, preserving first-seen order, keeping the original object
|
| 267 |
+
# so content-hashing (if enabled) sees the structured task.
|
| 268 |
+
seen: set[str] = set()
|
| 269 |
+
unique: list[Any] = []
|
| 270 |
+
for t in all_tasks:
|
| 271 |
+
tid = task_id_of(t)
|
| 272 |
+
if tid not in seen:
|
| 273 |
+
seen.add(tid)
|
| 274 |
+
unique.append(t)
|
| 275 |
+
|
| 276 |
+
n = len(unique)
|
| 277 |
+
n_holdout = round(n * holdout_frac)
|
| 278 |
+
# Clamp so a meaningful frac never collapses one side to empty.
|
| 279 |
+
if 0.0 < holdout_frac < 1.0 and n >= 2:
|
| 280 |
+
n_holdout = min(max(n_holdout, 1), n - 1)
|
| 281 |
+
|
| 282 |
+
# Deterministic shuffle on a COPY (does not mutate caller input).
|
| 283 |
+
order = list(unique)
|
| 284 |
+
random.Random(seed).shuffle(order)
|
| 285 |
+
holdout = order[:n_holdout]
|
| 286 |
+
train = order[n_holdout:]
|
| 287 |
+
return cls(train, holdout, check_content=check_content)
|
| 288 |
+
|
| 289 |
+
# ------------------------------------------------------------------------
|
| 290 |
+
# disjointness checks
|
| 291 |
+
# ------------------------------------------------------------------------
|
| 292 |
+
def overlapping_ids(self) -> tuple[str, ...]:
|
| 293 |
+
"""Sorted task ids present in BOTH pools (the id-based leak set)."""
|
| 294 |
+
return tuple(sorted(self.train_ids & self.holdout_ids))
|
| 295 |
+
|
| 296 |
+
def overlapping_content_hashes(self) -> tuple[str, ...]:
|
| 297 |
+
"""Sorted content hashes that collide across pools with DIFFERENT ids.
|
| 298 |
+
|
| 299 |
+
Empty when ``check_content`` is False. A hash present in both pools whose
|
| 300 |
+
only shared ids are already plain id-overlaps is not reported here (that
|
| 301 |
+
leak surfaces via ``overlapping_ids``); only collisions that involve at
|
| 302 |
+
least one DIFFERENT id on each side count, so the two checks do not
|
| 303 |
+
double-report the same leak.
|
| 304 |
+
"""
|
| 305 |
+
if not self.check_content:
|
| 306 |
+
return ()
|
| 307 |
+
id_overlap = self.train_ids & self.holdout_ids
|
| 308 |
+
bad: list[str] = []
|
| 309 |
+
for h, train_ids in self._train_hashes.items():
|
| 310 |
+
holdout_ids = self._holdout_hashes.get(h)
|
| 311 |
+
if holdout_ids is None:
|
| 312 |
+
continue
|
| 313 |
+
# Same content on both sides via at least one id that is NOT itself a
|
| 314 |
+
# plain id-overlap => a re-id'd near-duplicate leak.
|
| 315 |
+
if (holdout_ids - id_overlap) and (train_ids - id_overlap):
|
| 316 |
+
bad.append(h)
|
| 317 |
+
return tuple(sorted(bad))
|
| 318 |
+
|
| 319 |
+
@property
|
| 320 |
+
def is_disjoint(self) -> bool:
|
| 321 |
+
"""True iff the pools share no task id (and, when ``check_content``, no
|
| 322 |
+
cross-id near-duplicate content)."""
|
| 323 |
+
if self.train_ids & self.holdout_ids:
|
| 324 |
+
return False
|
| 325 |
+
if self.check_content and self.overlapping_content_hashes():
|
| 326 |
+
return False
|
| 327 |
+
return True
|
| 328 |
+
|
| 329 |
+
def validate(self) -> HeldoutSplit:
|
| 330 |
+
"""Assert disjointness; return ``self`` so it chains in a constructor.
|
| 331 |
+
|
| 332 |
+
Raises:
|
| 333 |
+
HeldoutOverlapError: listing the overlapping ids (and, when
|
| 334 |
+
``check_content``, the near-duplicate content hashes).
|
| 335 |
+
"""
|
| 336 |
+
id_overlap = self.overlapping_ids()
|
| 337 |
+
hash_overlap = self.overlapping_content_hashes()
|
| 338 |
+
if id_overlap or hash_overlap:
|
| 339 |
+
raise HeldoutOverlapError(id_overlap, hash_overlap)
|
| 340 |
+
return self
|
| 341 |
+
|
| 342 |
+
# Documented alias: the task spec names both `validate()` and
|
| 343 |
+
# `assert_disjoint()` — expose both so either calling convention works.
|
| 344 |
+
def assert_disjoint(self) -> HeldoutSplit:
|
| 345 |
+
"""Alias for ``validate()`` — raise ``HeldoutOverlapError`` on any leak."""
|
| 346 |
+
return self.validate()
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def _hash_index(tasks: Iterable[Any]) -> dict[str, frozenset[str]]:
|
| 350 |
+
"""Map content hash -> frozenset of task ids producing that hash."""
|
| 351 |
+
acc: dict[str, set[str]] = {}
|
| 352 |
+
for t in tasks:
|
| 353 |
+
acc.setdefault(content_hash(t), set()).add(task_id_of(t))
|
| 354 |
+
return {h: frozenset(ids) for h, ids in acc.items()}
|
|
@@ -401,20 +401,37 @@ class HeldOutGuard:
|
|
| 401 |
|
| 402 |
Args:
|
| 403 |
baseline_kls: per-step token-mean KL values from early in the run.
|
| 404 |
-
|
|
|
|
| 405 |
|
| 406 |
Returns:
|
| 407 |
-
The new ``kl_hard_stop`` (also stored on the instance).
|
| 408 |
|
| 409 |
Raises:
|
| 410 |
-
ValueError: if ``baseline_kls`` is empty
|
|
|
|
| 411 |
"""
|
| 412 |
if not baseline_kls:
|
| 413 |
raise ValueError("baseline_kls must be non-empty to calibrate")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
mean_kl = sum(baseline_kls) / len(baseline_kls)
|
| 415 |
calibrated = factor * mean_kl
|
| 416 |
# Only tighten: never let calibration loosen past the current ceiling.
|
| 417 |
-
|
|
|
|
|
|
|
| 418 |
return self.kl_hard_stop
|
| 419 |
|
| 420 |
# ------------------------------------------------------------------------
|
|
|
|
| 401 |
|
| 402 |
Args:
|
| 403 |
baseline_kls: per-step token-mean KL values from early in the run.
|
| 404 |
+
KL is non-negative by definition, so every value must be >= 0.
|
| 405 |
+
factor: multiplier on the baseline mean. Must be > 0.
|
| 406 |
|
| 407 |
Returns:
|
| 408 |
+
The new ``kl_hard_stop`` (also stored on the instance), always > 0.
|
| 409 |
|
| 410 |
Raises:
|
| 411 |
+
ValueError: if ``baseline_kls`` is empty, ``factor <= 0``, or any
|
| 412 |
+
baseline KL is negative.
|
| 413 |
"""
|
| 414 |
if not baseline_kls:
|
| 415 |
raise ValueError("baseline_kls must be non-empty to calibrate")
|
| 416 |
+
# GUARD (R4): a non-positive factor or a negative baseline would make
|
| 417 |
+
# `calibrated` <= 0, and min(<=0, 0.08) = a NON-POSITIVE kl_hard_stop —
|
| 418 |
+
# after which the KL tripwire fires on EVERY healthy step (any positive
|
| 419 |
+
# KL EMA exceeds a non-positive ceiling). KL is non-negative by
|
| 420 |
+
# definition, so these inputs are nonsensical; reject them loudly rather
|
| 421 |
+
# than silently disarm-by-inverting the guard.
|
| 422 |
+
if factor <= 0:
|
| 423 |
+
raise ValueError(f"factor must be > 0, got {factor!r}")
|
| 424 |
+
if any(k < 0 for k in baseline_kls):
|
| 425 |
+
raise ValueError(
|
| 426 |
+
f"baseline_kls must all be >= 0 (KL is non-negative); got a "
|
| 427 |
+
f"negative value in {baseline_kls!r}"
|
| 428 |
+
)
|
| 429 |
mean_kl = sum(baseline_kls) / len(baseline_kls)
|
| 430 |
calibrated = factor * mean_kl
|
| 431 |
# Only tighten: never let calibration loosen past the current ceiling.
|
| 432 |
+
# Floor at a small positive epsilon so an all-zero baseline (mean_kl==0)
|
| 433 |
+
# can't drive the ceiling to exactly 0 and fire on the first positive KL.
|
| 434 |
+
self.kl_hard_stop = max(min(calibrated, self.kl_hard_stop), 1e-6)
|
| 435 |
return self.kl_hard_stop
|
| 436 |
|
| 437 |
# ------------------------------------------------------------------------
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for HeldoutSplit — the train/held-out set-disjointness enforcer.
|
| 2 |
+
|
| 3 |
+
This is the second half of C1 (the first half is HeldOutGuard in kill_switch.py):
|
| 4 |
+
the guard's proxy-real-gap signal is only meaningful if the held-out eval set is
|
| 5 |
+
genuinely DISJOINT from the train/generator set. HeldoutSplit enforces that.
|
| 6 |
+
|
| 7 |
+
Written during Wave-3 integration (the build agent shipped holdout.py without a
|
| 8 |
+
test module — same test-gap pattern as the SageMaker/EKS executors).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from composer_replication.safety import HeldoutOverlapError, HeldoutSplit
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# A tiny FeatureDeletionTask-like stand-in (HeldoutSplit reads task_id + content
|
| 18 |
+
# fields via duck-typing; a plain object with task_id works, and a string is
|
| 19 |
+
# treated as its own id).
|
| 20 |
+
class _Task:
|
| 21 |
+
"""FeatureDeletionTask-like stand-in. content-hashing in holdout.py reads the
|
| 22 |
+
real task fields (repo/base_commit/test_command/...), so the content kwarg
|
| 23 |
+
populates `repo` (one of the hashed fields), not a generic `content` attr."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, task_id, content=""):
|
| 26 |
+
self.task_id = task_id
|
| 27 |
+
self.repo = content # `repo` is one of the fields _content_fields hashes
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------------
|
| 31 |
+
# id-based disjointness
|
| 32 |
+
# ---------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_disjoint_passes():
|
| 36 |
+
s = HeldoutSplit(train=["a", "b", "c"], holdout=["d", "e"])
|
| 37 |
+
assert s.is_disjoint
|
| 38 |
+
assert s.overlapping_ids() == ()
|
| 39 |
+
# validate / assert_disjoint return self and do not raise
|
| 40 |
+
assert s.validate() is s
|
| 41 |
+
assert s.assert_disjoint() is s
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_overlap_raises_and_lists_ids():
|
| 45 |
+
s = HeldoutSplit(train=["a", "b", "shared"], holdout=["shared", "z"])
|
| 46 |
+
assert not s.is_disjoint
|
| 47 |
+
assert s.overlapping_ids() == ("shared",)
|
| 48 |
+
with pytest.raises(HeldoutOverlapError) as exc:
|
| 49 |
+
s.validate()
|
| 50 |
+
assert "shared" in str(exc.value)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_object_tasks_use_task_id():
|
| 54 |
+
train = [_Task("t1"), _Task("t2")]
|
| 55 |
+
holdout = [_Task("h1")]
|
| 56 |
+
assert HeldoutSplit(train, holdout).is_disjoint
|
| 57 |
+
# an object sharing an id with train is a leak
|
| 58 |
+
leak = HeldoutSplit(train, [_Task("t1")])
|
| 59 |
+
assert not leak.is_disjoint
|
| 60 |
+
assert leak.overlapping_ids() == ("t1",)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------
|
| 64 |
+
# deterministic split()
|
| 65 |
+
# ---------------------------------------------------------------------
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_split_is_disjoint_by_construction():
|
| 69 |
+
pool = [f"task{i}" for i in range(10)]
|
| 70 |
+
s = HeldoutSplit.split(pool, holdout_frac=0.3, seed=0)
|
| 71 |
+
assert s.is_disjoint
|
| 72 |
+
# every id is on exactly one side; union covers the (de-duped) pool
|
| 73 |
+
assert s.train_ids.isdisjoint(s.holdout_ids)
|
| 74 |
+
assert (s.train_ids | s.holdout_ids) == set(pool)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_split_is_deterministic():
|
| 78 |
+
pool = [f"task{i}" for i in range(20)]
|
| 79 |
+
a = HeldoutSplit.split(pool, holdout_frac=0.25, seed=42)
|
| 80 |
+
b = HeldoutSplit.split(pool, holdout_frac=0.25, seed=42)
|
| 81 |
+
assert a.holdout_ids == b.holdout_ids
|
| 82 |
+
assert a.train_ids == b.train_ids
|
| 83 |
+
# a different seed gives a (very likely) different partition
|
| 84 |
+
c = HeldoutSplit.split(pool, holdout_frac=0.25, seed=7)
|
| 85 |
+
assert c.holdout_ids != a.holdout_ids or c.train_ids != a.train_ids
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_split_never_collapses_a_side():
|
| 89 |
+
s = HeldoutSplit.split([f"t{i}" for i in range(5)], holdout_frac=0.01, seed=0)
|
| 90 |
+
assert len(s.holdout_ids) >= 1
|
| 91 |
+
assert len(s.train_ids) >= 1
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_split_rejects_bad_frac():
|
| 95 |
+
with pytest.raises(ValueError):
|
| 96 |
+
HeldoutSplit.split(["a", "b"], holdout_frac=1.5)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_split_dedups_by_id():
|
| 100 |
+
# duplicate ids cannot land on both sides
|
| 101 |
+
pool = ["a", "a", "b", "c"]
|
| 102 |
+
s = HeldoutSplit.split(pool, holdout_frac=0.5, seed=0)
|
| 103 |
+
assert s.is_disjoint
|
| 104 |
+
assert (s.train_ids | s.holdout_ids) == {"a", "b", "c"}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------
|
| 108 |
+
# content-hash disjointness (catches same-content / different-id near-dups)
|
| 109 |
+
# ---------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_content_hash_catches_same_content_different_id():
|
| 113 |
+
# different ids, identical content -> id-disjoint but content-leaked
|
| 114 |
+
train = [_Task("t1", content="fix the off-by-one in range()")]
|
| 115 |
+
holdout = [_Task("h1", content="fix the off-by-one in range()")]
|
| 116 |
+
s = HeldoutSplit(train, holdout, check_content=True)
|
| 117 |
+
assert s.overlapping_ids() == () # ids are disjoint
|
| 118 |
+
assert not s.is_disjoint # but content collides
|
| 119 |
+
assert s.overlapping_content_hashes() # non-empty
|
| 120 |
+
with pytest.raises(HeldoutOverlapError):
|
| 121 |
+
s.validate()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_content_hash_disjoint_when_content_differs():
|
| 125 |
+
train = [_Task("t1", content="alpha")]
|
| 126 |
+
holdout = [_Task("h1", content="beta")]
|
| 127 |
+
s = HeldoutSplit(train, holdout, check_content=True)
|
| 128 |
+
assert s.is_disjoint
|
|
@@ -318,3 +318,55 @@ def test_kl_token_trust_filter_masks_above_threshold():
|
|
| 318 |
assert kl_token_trust_filter(0.20, threshold=0.08) is True # too large -> mask
|
| 319 |
assert kl_token_trust_filter(0.05, threshold=0.08) is False # within trust region
|
| 320 |
assert kl_token_trust_filter(0.08, threshold=0.08) is False # boundary, not masked
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
assert kl_token_trust_filter(0.20, threshold=0.08) is True # too large -> mask
|
| 319 |
assert kl_token_trust_filter(0.05, threshold=0.08) is False # within trust region
|
| 320 |
assert kl_token_trust_filter(0.08, threshold=0.08) is False # boundary, not masked
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# --- R4: calibrate_kl_threshold input guards (negative factor / baseline) -----
|
| 324 |
+
|
| 325 |
+
def test_calibrate_rejects_nonpositive_factor():
|
| 326 |
+
"""R4: a factor<=0 would make calibrated<=0 and min(<=0, 0.08)<=0, after
|
| 327 |
+
which the KL tripwire fires on every healthy step. Reject it loudly."""
|
| 328 |
+
g = _guard()
|
| 329 |
+
with pytest.raises(ValueError, match="factor must be > 0"):
|
| 330 |
+
g.calibrate_kl_threshold([0.01, 0.02], factor=-3.0)
|
| 331 |
+
with pytest.raises(ValueError, match="factor must be > 0"):
|
| 332 |
+
g.calibrate_kl_threshold([0.01, 0.02], factor=0.0)
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def test_calibrate_rejects_negative_baseline_kl():
|
| 336 |
+
"""R4: KL is non-negative by definition; a negative baseline is nonsensical
|
| 337 |
+
and could invert the ceiling. Reject it."""
|
| 338 |
+
g = _guard()
|
| 339 |
+
with pytest.raises(ValueError, match="non-negative"):
|
| 340 |
+
g.calibrate_kl_threshold([0.01, -0.5, 0.02])
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def test_calibrate_never_yields_nonpositive_threshold():
|
| 344 |
+
"""R4: even an all-zero baseline (mean 0) must leave a positive ceiling so a
|
| 345 |
+
later positive KL doesn't fire spuriously."""
|
| 346 |
+
g = _guard()
|
| 347 |
+
out = g.calibrate_kl_threshold([0.0, 0.0, 0.0], factor=3.0)
|
| 348 |
+
assert out > 0.0
|
| 349 |
+
assert g.kl_hard_stop > 0.0
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# --- R10: path-(c) gap-blowout is a divergence-RATE gate, not a real-decline --
|
| 353 |
+
|
| 354 |
+
def test_gap_blowout_fires_even_when_real_still_rising():
|
| 355 |
+
"""R10: path (c) fires when the proxy gain outpaces the real gain beyond the
|
| 356 |
+
ceiling EVEN WHILE the held-out (real) score is still genuinely RISING. This
|
| 357 |
+
is INTENTIONAL — path (c) is a divergence-RATE gate (fast single-generation
|
| 358 |
+
hacking), distinct from path (a)'s real-decline streak. Locking the intended
|
| 359 |
+
behavior so a future change can't silently turn it into a real-decline gate."""
|
| 360 |
+
g = _guard(max_proxy_real_gap=0.1, decline_patience=99) # isolate path (c) from (a)
|
| 361 |
+
status = None
|
| 362 |
+
for i in range(8):
|
| 363 |
+
status = g.update(
|
| 364 |
+
i,
|
| 365 |
+
in_loop_reward=0.30 + 0.20 * i, # proxy sprints
|
| 366 |
+
heldout_score=0.30 + 0.01 * i, # real still rising, but slowly
|
| 367 |
+
kl_to_init=0.02,
|
| 368 |
+
)
|
| 369 |
+
assert status.fire, "path (c) should fire on a fast proxy/real divergence"
|
| 370 |
+
assert "gap" in status.reason.lower()
|
| 371 |
+
# And the real score WAS rising the whole time (not a decline-driven fire).
|
| 372 |
+
assert status.heldout_ema > g._fold(None, 0.30) # type: ignore[attr-defined]
|
|
@@ -26,10 +26,14 @@ The data collator (data_collator.py) is responsible for:
|
|
| 26 |
from __future__ import annotations
|
| 27 |
|
| 28 |
import logging
|
| 29 |
-
from
|
|
|
|
| 30 |
|
| 31 |
import torch
|
| 32 |
-
import torch.nn.functional as F
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# These imports work when TRL is installed — they're not skeleton imports.
|
| 35 |
# When TRL is missing we fall back to `object` so the module still imports
|
|
@@ -63,6 +67,25 @@ class ComposerReplicationTrainer(GRPOTrainer): # type: ignore[misc, valid-type]
|
|
| 63 |
sdpo_temperature: temperature for SDPO loss; SDPO paper uses 1.0.
|
| 64 |
sdpo_token_clip: per-token JSD clip for stability; None = no clip.
|
| 65 |
replay_dpo_beta: beta param of the DPO loss (β in the standard DPO formula).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
"""
|
| 67 |
|
| 68 |
def __init__(
|
|
@@ -75,6 +98,9 @@ class ComposerReplicationTrainer(GRPOTrainer): # type: ignore[misc, valid-type]
|
|
| 75 |
sdpo_token_clip: float | None = None,
|
| 76 |
replay_dpo_beta: float = 0.1,
|
| 77 |
strict_sdpo_alignment: bool = True,
|
|
|
|
|
|
|
|
|
|
| 78 |
**kwargs: Any,
|
| 79 |
):
|
| 80 |
if not _TRL_AVAILABLE:
|
|
@@ -95,6 +121,21 @@ class ComposerReplicationTrainer(GRPOTrainer): # type: ignore[misc, valid-type]
|
|
| 95 |
# trust-gap flagged in ADR-008). Set False only for production runs
|
| 96 |
# where a single malformed batch should warn-and-skip rather than abort.
|
| 97 |
self.strict_sdpo_alignment = strict_sdpo_alignment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
# ----------------------------------------------------------------------
|
| 100 |
# Loss override (the integration core)
|
|
@@ -130,9 +171,107 @@ class ComposerReplicationTrainer(GRPOTrainer): # type: ignore[misc, valid-type]
|
|
| 130 |
"loss/alpha_sdpo": self.alpha_sdpo,
|
| 131 |
"loss/beta_replay": self.beta_replay,
|
| 132 |
})
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
return total
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
# ----------------------------------------------------------------------
|
| 137 |
# Channel 2: SDPO hint-distill
|
| 138 |
# ----------------------------------------------------------------------
|
|
|
|
| 26 |
from __future__ import annotations
|
| 27 |
|
| 28 |
import logging
|
| 29 |
+
from collections.abc import Callable
|
| 30 |
+
from typing import TYPE_CHECKING, Any
|
| 31 |
|
| 32 |
import torch
|
| 33 |
+
import torch.nn.functional as F # noqa: N812 — repo-wide torch convention
|
| 34 |
+
|
| 35 |
+
if TYPE_CHECKING: # type-only — never imported at runtime (keeps the dep lazy)
|
| 36 |
+
from composer_replication.safety import HeldOutGuard
|
| 37 |
|
| 38 |
# These imports work when TRL is installed — they're not skeleton imports.
|
| 39 |
# When TRL is missing we fall back to `object` so the module still imports
|
|
|
|
| 67 |
sdpo_temperature: temperature for SDPO loss; SDPO paper uses 1.0.
|
| 68 |
sdpo_token_clip: per-token JSD clip for stability; None = no clip.
|
| 69 |
replay_dpo_beta: beta param of the DPO loss (β in the standard DPO formula).
|
| 70 |
+
heldout_guard: optional ``HeldOutGuard`` (the #2 collapse safeguard from
|
| 71 |
+
``composer_replication.safety``). Default None = OFF (no behavior
|
| 72 |
+
change whatsoever). When supplied, the trainer folds one checkpoint's
|
| 73 |
+
metrics into the guard at the ``args.logging_steps`` cadence (the same
|
| 74 |
+
place the loss components are logged) and HALTS the run on a fired
|
| 75 |
+
verdict — the run-level reward-hacking / collapse tripwire actually
|
| 76 |
+
firing instead of sitting inert.
|
| 77 |
+
heldout_eval_fn: zero-arg callable returning the held-out (real) eval
|
| 78 |
+
score as a float, evaluated each guard cadence. Injectable so the
|
| 79 |
+
trainer never hardcodes an eval — pass a closure over your disjoint
|
| 80 |
+
held-out pool (the ``HeldoutSplit`` discipline). Required whenever
|
| 81 |
+
``heldout_guard`` is set; the guard's whole signal is in-loop reward
|
| 82 |
+
vs. this held-out score.
|
| 83 |
+
strict_killswitch: when True (default), a fired guard verdict raises
|
| 84 |
+
``CollapseStopError`` to hard-stop training (exception-based control
|
| 85 |
+
flow, matching ``HeldOutGuard.raise_if_fired``). When False the
|
| 86 |
+
verdict is logged and ``self.control.should_training_stop`` is set so
|
| 87 |
+
the HF loop ends gracefully after the step (soft stop). Only consulted
|
| 88 |
+
when ``heldout_guard`` is set.
|
| 89 |
"""
|
| 90 |
|
| 91 |
def __init__(
|
|
|
|
| 98 |
sdpo_token_clip: float | None = None,
|
| 99 |
replay_dpo_beta: float = 0.1,
|
| 100 |
strict_sdpo_alignment: bool = True,
|
| 101 |
+
heldout_guard: HeldOutGuard | None = None,
|
| 102 |
+
heldout_eval_fn: Callable[[], float] | None = None,
|
| 103 |
+
strict_killswitch: bool = True,
|
| 104 |
**kwargs: Any,
|
| 105 |
):
|
| 106 |
if not _TRL_AVAILABLE:
|
|
|
|
| 121 |
# trust-gap flagged in ADR-008). Set False only for production runs
|
| 122 |
# where a single malformed batch should warn-and-skip rather than abort.
|
| 123 |
self.strict_sdpo_alignment = strict_sdpo_alignment
|
| 124 |
+
# --- run-level collapse kill-switch (#2 safeguard) -------------------
|
| 125 |
+
# OPTIONAL + OFF BY DEFAULT: when heldout_guard is None the loss path is
|
| 126 |
+
# byte-for-byte the legacy behavior. When set, _maybe_update_killswitch
|
| 127 |
+
# folds metrics into the guard at the logging cadence (see _compute_loss).
|
| 128 |
+
self.heldout_guard = heldout_guard
|
| 129 |
+
self.heldout_eval_fn = heldout_eval_fn
|
| 130 |
+
self.strict_killswitch = strict_killswitch
|
| 131 |
+
if heldout_guard is not None and heldout_eval_fn is None:
|
| 132 |
+
raise ValueError(
|
| 133 |
+
"heldout_guard was provided without heldout_eval_fn: the guard's "
|
| 134 |
+
"tripwire compares in-loop reward against a DISJOINT held-out "
|
| 135 |
+
"(real) eval score, so it needs an injectable zero-arg "
|
| 136 |
+
"heldout_eval_fn() -> float. Pass a closure over your held-out "
|
| 137 |
+
"pool (the HeldoutSplit discipline)."
|
| 138 |
+
)
|
| 139 |
|
| 140 |
# ----------------------------------------------------------------------
|
| 141 |
# Loss override (the integration core)
|
|
|
|
| 171 |
"loss/alpha_sdpo": self.alpha_sdpo,
|
| 172 |
"loss/beta_replay": self.beta_replay,
|
| 173 |
})
|
| 174 |
+
# Fold one checkpoint into the run-level collapse kill-switch at
|
| 175 |
+
# the SAME cadence (no-op unless a guard was configured).
|
| 176 |
+
self._maybe_update_killswitch()
|
| 177 |
|
| 178 |
return total
|
| 179 |
|
| 180 |
+
# ----------------------------------------------------------------------
|
| 181 |
+
# Run-level collapse kill-switch (#2 safeguard) — optional, OFF by default
|
| 182 |
+
# ----------------------------------------------------------------------
|
| 183 |
+
|
| 184 |
+
def _maybe_update_killswitch(self) -> None:
|
| 185 |
+
"""Fold this checkpoint's metrics into ``heldout_guard`` and act on a fire.
|
| 186 |
+
|
| 187 |
+
No-op when no guard was configured (the default) — this is the
|
| 188 |
+
backward-compat guarantee: without ``heldout_guard`` the trainer behaves
|
| 189 |
+
exactly as before. When a guard IS set:
|
| 190 |
+
|
| 191 |
+
* ``in_loop_reward`` is the GRPO reward signal TRL already aggregates
|
| 192 |
+
into ``self._metrics[mode]["reward"]`` each step (we read the latest;
|
| 193 |
+
no extra forward pass).
|
| 194 |
+
* ``heldout_score`` comes from the injected ``heldout_eval_fn()`` — the
|
| 195 |
+
trainer never hardcodes an eval.
|
| 196 |
+
* ``kl_to_init`` (token-mean nats/token, the ``token_mean_kl``
|
| 197 |
+
convention the guard expects) is read from TRL's logged ``"kl"``
|
| 198 |
+
metric when present, else left None (KL path stays inert).
|
| 199 |
+
|
| 200 |
+
On a fired verdict the verdict is logged. If ``strict_killswitch`` (the
|
| 201 |
+
default) the verdict is converted into a ``CollapseStopError`` via
|
| 202 |
+
``HeldOutGuard.raise_if_fired`` (hard stop); otherwise the HF training
|
| 203 |
+
loop is asked to stop gracefully after this step.
|
| 204 |
+
"""
|
| 205 |
+
guard = self.heldout_guard
|
| 206 |
+
if guard is None:
|
| 207 |
+
return # OFF by default — zero behavior change
|
| 208 |
+
|
| 209 |
+
round_idx = int(getattr(self.state, "global_step", 0))
|
| 210 |
+
in_loop_reward = self._latest_metric("reward")
|
| 211 |
+
if in_loop_reward is None:
|
| 212 |
+
# No reward aggregated yet (e.g. very first micro-step before TRL has
|
| 213 |
+
# populated its metrics). Skip this cadence rather than feed a
|
| 214 |
+
# fabricated 0.0 that would pollute the guard's baseline/EMA.
|
| 215 |
+
logger.debug(
|
| 216 |
+
"kill-switch: no in-loop reward metric yet at step %d; skipping.",
|
| 217 |
+
round_idx,
|
| 218 |
+
)
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
assert self.heldout_eval_fn is not None # enforced in __init__
|
| 222 |
+
heldout_score = float(self.heldout_eval_fn())
|
| 223 |
+
kl_to_init = self._latest_metric("kl") # token-mean KL, or None
|
| 224 |
+
|
| 225 |
+
status = guard.update(
|
| 226 |
+
round_idx=round_idx,
|
| 227 |
+
in_loop_reward=in_loop_reward,
|
| 228 |
+
heldout_score=heldout_score,
|
| 229 |
+
kl_to_init=kl_to_init,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
self.log({ # type: ignore[attr-defined]
|
| 233 |
+
"killswitch/in_loop_reward": status.in_loop_ema,
|
| 234 |
+
"killswitch/heldout_score": status.heldout_ema,
|
| 235 |
+
"killswitch/proxy_real_gap": status.proxy_real_gap,
|
| 236 |
+
"killswitch/fire": float(status.fire),
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
if status.fire:
|
| 240 |
+
logger.error(
|
| 241 |
+
"HeldOutGuard FIRED at step %d — halting run. reason: %s",
|
| 242 |
+
round_idx, status.reason,
|
| 243 |
+
)
|
| 244 |
+
if self.strict_killswitch:
|
| 245 |
+
# Typed exception — exception-based hard stop.
|
| 246 |
+
guard.raise_if_fired(status)
|
| 247 |
+
else:
|
| 248 |
+
# Soft stop: let the HF loop terminate gracefully after this step.
|
| 249 |
+
control = getattr(self, "control", None)
|
| 250 |
+
if control is not None:
|
| 251 |
+
control.should_training_stop = True
|
| 252 |
+
|
| 253 |
+
def _latest_metric(self, name: str) -> float | None:
|
| 254 |
+
"""Most-recent value of a TRL-aggregated train metric, or None.
|
| 255 |
+
|
| 256 |
+
TRL's GRPOTrainer appends per-step aggregates to
|
| 257 |
+
``self._metrics["train"][name]`` (e.g. ``"reward"``, ``"kl"``). We read
|
| 258 |
+
the tail defensively so a TRL internals rename degrades to None (KL/reward
|
| 259 |
+
path goes inert) rather than crashing training.
|
| 260 |
+
"""
|
| 261 |
+
metrics = getattr(self, "_metrics", None)
|
| 262 |
+
if not isinstance(metrics, dict):
|
| 263 |
+
return None
|
| 264 |
+
train = metrics.get("train")
|
| 265 |
+
if not isinstance(train, dict):
|
| 266 |
+
return None
|
| 267 |
+
series = train.get(name)
|
| 268 |
+
if not series:
|
| 269 |
+
return None
|
| 270 |
+
try:
|
| 271 |
+
return float(series[-1])
|
| 272 |
+
except (TypeError, ValueError, IndexError):
|
| 273 |
+
return None
|
| 274 |
+
|
| 275 |
# ----------------------------------------------------------------------
|
| 276 |
# Channel 2: SDPO hint-distill
|
| 277 |
# ----------------------------------------------------------------------
|
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""R1 — HeldOutGuard wired into ComposerReplicationTrainer (the #2 safeguard).
|
| 2 |
+
|
| 3 |
+
These tests close the "tripwire exists but never fires" gap: the run-level
|
| 4 |
+
collapse kill-switch (``composer_replication.safety.HeldOutGuard``) must actually
|
| 5 |
+
be folded by the trainer at its logging cadence, fed the in-loop GRPO reward and
|
| 6 |
+
an INJECTED held-out eval, and halt the run on a fired verdict.
|
| 7 |
+
|
| 8 |
+
Acceptance gates:
|
| 9 |
+
1. BACKWARD-COMPAT: no ``heldout_guard`` => ``_maybe_update_killswitch`` is a
|
| 10 |
+
pure no-op (never touches the eval fn, never logs) — identical behavior.
|
| 11 |
+
2. THE WIRING: a fake ``heldout_eval_fn`` that DECLINES while the in-loop
|
| 12 |
+
reward RISES drives the guard to fire; strict mode raises
|
| 13 |
+
``CollapseStopError`` and the verdict carries the reward-hacking signature.
|
| 14 |
+
3. Soft stop: ``strict_killswitch=False`` => no raise, the HF loop is asked to
|
| 15 |
+
stop (``control.should_training_stop``).
|
| 16 |
+
4. Healthy run (held-out tracks reward) never fires.
|
| 17 |
+
5. KL-to-init is read from TRL's logged metric and reaches the guard.
|
| 18 |
+
6. Constructor contract: ``heldout_guard`` without ``heldout_eval_fn`` raises.
|
| 19 |
+
7. ``_compute_loss`` only folds the guard at the ``logging_steps`` cadence
|
| 20 |
+
(not every micro-step), mirroring the loss-component logging.
|
| 21 |
+
|
| 22 |
+
CPU-only, no model download, no full GRPOTrainer init (stub instance via
|
| 23 |
+
``__new__`` + manual attribute wiring, the pattern used by the SDPO tests).
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
from composer_replication.safety import CollapseStopError, HeldOutGuard
|
| 30 |
+
from composer_replication.trainer.composer_trainer import ComposerReplicationTrainer
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Stubs — mirror _make_sdpo_trainer in test_sdpo_alignment_indices.py: build the
|
| 34 |
+
# trainer via __new__ so we never run GRPOTrainer.__init__ (no TRL setup, no
|
| 35 |
+
# model download), then wire only the attributes the kill-switch path reads.
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
|
| 38 |
+
class _State:
|
| 39 |
+
def __init__(self, global_step: int = 0) -> None:
|
| 40 |
+
self.global_step = global_step
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class _Args:
|
| 44 |
+
def __init__(self, logging_steps: int = 1) -> None:
|
| 45 |
+
self.logging_steps = logging_steps
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class _Control:
|
| 49 |
+
def __init__(self) -> None:
|
| 50 |
+
self.should_training_stop = False
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _make_killswitch_trainer(
|
| 54 |
+
guard: HeldOutGuard | None,
|
| 55 |
+
eval_fn,
|
| 56 |
+
*,
|
| 57 |
+
strict: bool = True,
|
| 58 |
+
reward: float | None = 0.40,
|
| 59 |
+
kl: float | None = None,
|
| 60 |
+
):
|
| 61 |
+
"""A ComposerReplicationTrainer stub exposing only what the kill-switch reads.
|
| 62 |
+
|
| 63 |
+
``reward`` / ``kl`` seed TRL's per-step metric series (the trainer reads the
|
| 64 |
+
tail of ``self._metrics["train"][name]``). Pass reward=None to simulate "no
|
| 65 |
+
reward aggregated yet".
|
| 66 |
+
"""
|
| 67 |
+
obj = ComposerReplicationTrainer.__new__(ComposerReplicationTrainer)
|
| 68 |
+
obj.heldout_guard = guard
|
| 69 |
+
obj.heldout_eval_fn = eval_fn
|
| 70 |
+
obj.strict_killswitch = strict
|
| 71 |
+
obj.state = _State(global_step=0)
|
| 72 |
+
obj.args = _Args(logging_steps=1)
|
| 73 |
+
obj.control = _Control()
|
| 74 |
+
train_metrics: dict[str, list] = {}
|
| 75 |
+
if reward is not None:
|
| 76 |
+
train_metrics["reward"] = [reward]
|
| 77 |
+
if kl is not None:
|
| 78 |
+
train_metrics["kl"] = [kl]
|
| 79 |
+
obj._metrics = {"train": train_metrics}
|
| 80 |
+
obj.logged: list[dict] = []
|
| 81 |
+
# capture self.log(...) instead of routing through HF Trainer.log
|
| 82 |
+
obj.log = obj.logged.append # type: ignore[assignment]
|
| 83 |
+
return obj
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _set_step_reward(obj, step: int, reward: float, kl: float | None = None) -> None:
|
| 87 |
+
obj.state.global_step = step
|
| 88 |
+
obj._metrics["train"].setdefault("reward", []).append(reward)
|
| 89 |
+
if kl is not None:
|
| 90 |
+
obj._metrics["train"].setdefault("kl", []).append(kl)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Gate 1 — backward compatibility: absent guard is a pure no-op
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def test_absent_guard_is_noop():
|
| 98 |
+
"""No heldout_guard => the kill-switch path does nothing: the eval fn is
|
| 99 |
+
never called, nothing is logged, no exception. This is the backward-compat
|
| 100 |
+
guarantee (no kwarg => identical behavior)."""
|
| 101 |
+
calls = {"n": 0}
|
| 102 |
+
|
| 103 |
+
def eval_fn() -> float:
|
| 104 |
+
calls["n"] += 1
|
| 105 |
+
return 0.0
|
| 106 |
+
|
| 107 |
+
# Pass eval_fn but NO guard — the helper must never reach the eval fn.
|
| 108 |
+
obj = _make_killswitch_trainer(guard=None, eval_fn=eval_fn)
|
| 109 |
+
for step in range(50):
|
| 110 |
+
_set_step_reward(obj, step, reward=0.40 + 0.05 * step)
|
| 111 |
+
obj._maybe_update_killswitch() # must be a no-op
|
| 112 |
+
|
| 113 |
+
assert calls["n"] == 0, "held-out eval fn was called even though no guard set"
|
| 114 |
+
assert obj.logged == [], "kill-switch logged even though no guard configured"
|
| 115 |
+
assert obj.control.should_training_stop is False
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_constructor_defaults_leave_killswitch_off():
|
| 119 |
+
"""The constructor defaults: a trainer built without the kwargs has
|
| 120 |
+
heldout_guard=None / strict_killswitch defaulting on but inert."""
|
| 121 |
+
obj = ComposerReplicationTrainer.__new__(ComposerReplicationTrainer)
|
| 122 |
+
# Simulate the default-kwarg assignment __init__ performs.
|
| 123 |
+
obj.heldout_guard = None
|
| 124 |
+
obj.heldout_eval_fn = None
|
| 125 |
+
obj.strict_killswitch = True
|
| 126 |
+
obj._maybe_update_killswitch() # no state needed: returns immediately on None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ---------------------------------------------------------------------------
|
| 130 |
+
# Gate 2 — THE WIRING: declining held-out + rising reward => guard fires & raises
|
| 131 |
+
# ---------------------------------------------------------------------------
|
| 132 |
+
|
| 133 |
+
def test_guard_fires_and_raises_on_reward_hacking_signature():
|
| 134 |
+
"""Fake heldout_eval_fn DECLINES while the in-loop reward RISES — the
|
| 135 |
+
canonical reward-hacking signature. The wired guard must fire and (strict
|
| 136 |
+
mode) raise CollapseStopError with the reward-hacking reason."""
|
| 137 |
+
# min_steps small so the test is fast; isolate the decline-streak path.
|
| 138 |
+
guard = HeldOutGuard(
|
| 139 |
+
min_steps=3, decline_patience=3, ema_alpha=0.5, max_proxy_real_gap=10.0
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# held-out declines every call; reward (TRL metric) rises every step.
|
| 143 |
+
heldout = {"v": 0.80}
|
| 144 |
+
|
| 145 |
+
def declining_eval() -> float:
|
| 146 |
+
heldout["v"] -= 0.05
|
| 147 |
+
return heldout["v"]
|
| 148 |
+
|
| 149 |
+
obj = _make_killswitch_trainer(guard, declining_eval, strict=True)
|
| 150 |
+
|
| 151 |
+
raised = None
|
| 152 |
+
for step in range(1, 30):
|
| 153 |
+
# rising in-loop reward fed via the TRL metric tail
|
| 154 |
+
_set_step_reward(obj, step, reward=0.30 + 0.03 * step)
|
| 155 |
+
try:
|
| 156 |
+
obj._maybe_update_killswitch()
|
| 157 |
+
except CollapseStopError as exc:
|
| 158 |
+
raised = exc
|
| 159 |
+
break
|
| 160 |
+
|
| 161 |
+
assert raised is not None, "guard never fired on the reward-hacking signature"
|
| 162 |
+
assert guard.should_halt()
|
| 163 |
+
assert raised.status.fire
|
| 164 |
+
# The fired verdict must be the held-out-declines-while-reward-rises signature.
|
| 165 |
+
assert "held-out" in raised.status.reason
|
| 166 |
+
assert raised.status.proxy_real_gap > 0.0 # proxy gained while real lost
|
| 167 |
+
# And the kill-switch logged the verdict before raising.
|
| 168 |
+
assert any("killswitch/fire" in d for d in obj.logged)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def test_soft_stop_sets_control_instead_of_raising():
|
| 172 |
+
"""strict_killswitch=False => a fired verdict does NOT raise; it sets the HF
|
| 173 |
+
loop's control.should_training_stop so training ends gracefully."""
|
| 174 |
+
guard = HeldOutGuard(
|
| 175 |
+
min_steps=3, decline_patience=3, ema_alpha=0.5, max_proxy_real_gap=10.0
|
| 176 |
+
)
|
| 177 |
+
heldout = {"v": 0.80}
|
| 178 |
+
|
| 179 |
+
def declining_eval() -> float:
|
| 180 |
+
heldout["v"] -= 0.05
|
| 181 |
+
return heldout["v"]
|
| 182 |
+
|
| 183 |
+
obj = _make_killswitch_trainer(guard, declining_eval, strict=False)
|
| 184 |
+
|
| 185 |
+
for step in range(1, 30):
|
| 186 |
+
_set_step_reward(obj, step, reward=0.30 + 0.03 * step)
|
| 187 |
+
obj._maybe_update_killswitch() # must NOT raise
|
| 188 |
+
if obj.control.should_training_stop:
|
| 189 |
+
break
|
| 190 |
+
|
| 191 |
+
assert obj.control.should_training_stop is True, (
|
| 192 |
+
"soft-stop guard fired but did not request training stop"
|
| 193 |
+
)
|
| 194 |
+
assert guard.should_halt()
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
# Gate 4 — healthy run never fires
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
|
| 201 |
+
def test_healthy_run_never_fires():
|
| 202 |
+
"""Held-out tracks the in-loop reward (both rise together), KL in band =>
|
| 203 |
+
the wired guard never fires and training is never asked to stop."""
|
| 204 |
+
guard = HeldOutGuard(
|
| 205 |
+
min_steps=3, decline_patience=3, ema_alpha=0.5, kl_hard_stop=0.08,
|
| 206 |
+
max_proxy_real_gap=10.0,
|
| 207 |
+
)
|
| 208 |
+
heldout = {"v": 0.28}
|
| 209 |
+
|
| 210 |
+
def rising_eval() -> float:
|
| 211 |
+
heldout["v"] += 0.01
|
| 212 |
+
return heldout["v"]
|
| 213 |
+
|
| 214 |
+
obj = _make_killswitch_trainer(guard, rising_eval, strict=True, kl=0.03)
|
| 215 |
+
|
| 216 |
+
for step in range(1, 40):
|
| 217 |
+
_set_step_reward(obj, step, reward=0.30 + 0.01 * step, kl=0.03)
|
| 218 |
+
obj._maybe_update_killswitch() # must never raise on a healthy run
|
| 219 |
+
|
| 220 |
+
assert not guard.should_halt()
|
| 221 |
+
assert obj.control.should_training_stop is False
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
# Gate 5 — KL-to-init from TRL's logged metric reaches the guard
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def test_kl_to_init_is_forwarded_to_guard():
|
| 229 |
+
"""The KL the trainer reads from TRL's "kl" metric must reach the guard's
|
| 230 |
+
kl_ema (proves kl_to_init wiring), and a KL breach fires via the KL path."""
|
| 231 |
+
guard = HeldOutGuard(
|
| 232 |
+
min_steps=3, decline_patience=100, ema_alpha=0.5, kl_hard_stop=0.08,
|
| 233 |
+
max_proxy_real_gap=10.0, # isolate the KL path
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
obj = _make_killswitch_trainer(guard, lambda: 0.40, strict=True, kl=0.04)
|
| 237 |
+
|
| 238 |
+
# Warm-up with healthy KL; metrics flat so only the KL path can fire.
|
| 239 |
+
for step in range(1, 5):
|
| 240 |
+
_set_step_reward(obj, step, reward=0.40, kl=0.04)
|
| 241 |
+
obj._maybe_update_killswitch()
|
| 242 |
+
assert guard.last_status is not None and guard.last_status.kl_ema is not None, (
|
| 243 |
+
"kl_to_init never reached the guard — KL wiring is broken"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
# KL spikes above the hard stop; EMA climbs and crosses => fire via KL path.
|
| 247 |
+
raised = None
|
| 248 |
+
for step in range(5, 20):
|
| 249 |
+
_set_step_reward(obj, step, reward=0.40, kl=0.30)
|
| 250 |
+
try:
|
| 251 |
+
obj._maybe_update_killswitch()
|
| 252 |
+
except CollapseStopError as exc:
|
| 253 |
+
raised = exc
|
| 254 |
+
break
|
| 255 |
+
assert raised is not None, "KL hard-stop never fired through the wired guard"
|
| 256 |
+
assert "kl_to_init" in raised.status.reason
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def test_no_reward_metric_yet_skips_cleanly():
|
| 260 |
+
"""Before TRL has aggregated any reward (empty metric series), the helper
|
| 261 |
+
skips the fold rather than feeding a fabricated 0.0 into the guard's EMA."""
|
| 262 |
+
guard = HeldOutGuard(min_steps=3, ema_alpha=0.5)
|
| 263 |
+
calls = {"n": 0}
|
| 264 |
+
|
| 265 |
+
def eval_fn() -> float:
|
| 266 |
+
calls["n"] += 1
|
| 267 |
+
return 0.40
|
| 268 |
+
|
| 269 |
+
obj = _make_killswitch_trainer(guard, eval_fn, reward=None)
|
| 270 |
+
obj._maybe_update_killswitch() # no reward series => skip
|
| 271 |
+
|
| 272 |
+
assert calls["n"] == 0, "eval fn called despite no in-loop reward yet"
|
| 273 |
+
assert guard.last_status is None, "guard advanced despite no reward metric"
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
# Gate 6 — constructor contract
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
|
| 280 |
+
def test_guard_without_eval_fn_raises_at_construction(monkeypatch):
|
| 281 |
+
"""A guard with no held-out eval is meaningless (the tripwire needs the
|
| 282 |
+
held-out signal) => the REAL __init__ must reject it loudly. We stub the
|
| 283 |
+
GRPOTrainer parent __init__ so the validation clause runs without a full
|
| 284 |
+
TRL/model setup."""
|
| 285 |
+
parent = ComposerReplicationTrainer.__bases__[0]
|
| 286 |
+
monkeypatch.setattr(parent, "__init__", lambda self, *a, **k: None, raising=False)
|
| 287 |
+
|
| 288 |
+
guard = HeldOutGuard(min_steps=3)
|
| 289 |
+
with pytest.raises(ValueError, match="heldout_eval_fn"):
|
| 290 |
+
ComposerReplicationTrainer(heldout_guard=guard) # no heldout_eval_fn
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_guard_with_eval_fn_constructs_and_stays_off_when_absent(monkeypatch):
|
| 294 |
+
"""The real __init__ wires the kill-switch attributes; with both provided it
|
| 295 |
+
constructs cleanly, and with neither provided the guard stays None (the
|
| 296 |
+
default = OFF backward-compat path)."""
|
| 297 |
+
parent = ComposerReplicationTrainer.__bases__[0]
|
| 298 |
+
monkeypatch.setattr(parent, "__init__", lambda self, *a, **k: None, raising=False)
|
| 299 |
+
|
| 300 |
+
# Both provided => constructs, guard wired.
|
| 301 |
+
guard = HeldOutGuard(min_steps=3)
|
| 302 |
+
t = ComposerReplicationTrainer(heldout_guard=guard, heldout_eval_fn=lambda: 0.4)
|
| 303 |
+
assert t.heldout_guard is guard
|
| 304 |
+
assert t.strict_killswitch is True # strict default
|
| 305 |
+
|
| 306 |
+
# Neither provided => guard stays None (OFF).
|
| 307 |
+
t2 = ComposerReplicationTrainer()
|
| 308 |
+
assert t2.heldout_guard is None
|
| 309 |
+
assert t2.heldout_eval_fn is None
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
# Gate 7 — _compute_loss only folds the guard at the logging cadence
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
|
| 316 |
+
def test_compute_loss_folds_guard_only_at_logging_cadence(monkeypatch):
|
| 317 |
+
"""Drive _compute_loss end-to-end (with the GRPO parent loss + SDPO/replay
|
| 318 |
+
channels stubbed) and assert the guard is folded ONLY on logging-cadence
|
| 319 |
+
steps — i.e. the kill-switch fold sits inside the same cadence gate as the
|
| 320 |
+
loss-component logging, not on every micro-step."""
|
| 321 |
+
import torch
|
| 322 |
+
|
| 323 |
+
folds = {"n": 0}
|
| 324 |
+
guard = HeldOutGuard(min_steps=10_000, ema_alpha=0.5) # never fires in this test
|
| 325 |
+
|
| 326 |
+
def counting_eval() -> float:
|
| 327 |
+
folds["n"] += 1
|
| 328 |
+
return 0.40
|
| 329 |
+
|
| 330 |
+
obj = ComposerReplicationTrainer.__new__(ComposerReplicationTrainer)
|
| 331 |
+
obj.alpha_sdpo = 0.0
|
| 332 |
+
obj.beta_replay = 0.0
|
| 333 |
+
obj.heldout_guard = guard
|
| 334 |
+
obj.heldout_eval_fn = counting_eval
|
| 335 |
+
obj.strict_killswitch = True
|
| 336 |
+
obj.state = _State(global_step=0)
|
| 337 |
+
obj.args = _Args(logging_steps=10)
|
| 338 |
+
obj.control = _Control()
|
| 339 |
+
obj._metrics = {"train": {"reward": [0.40]}}
|
| 340 |
+
obj.logged = []
|
| 341 |
+
obj.log = obj.logged.append # type: ignore[assignment]
|
| 342 |
+
|
| 343 |
+
# Stub the GRPO parent loss (the real `super()._compute_loss` would need a
|
| 344 |
+
# full TRL trainer) and the SDPO / replay channels to zero. We patch the
|
| 345 |
+
# PARENT class's _compute_loss so `super()._compute_loss(...)` resolves to it.
|
| 346 |
+
parent = ComposerReplicationTrainer.__bases__[0]
|
| 347 |
+
monkeypatch.setattr(
|
| 348 |
+
parent, "_compute_loss",
|
| 349 |
+
lambda self, model, inputs: torch.tensor(1.0),
|
| 350 |
+
raising=False,
|
| 351 |
+
)
|
| 352 |
+
monkeypatch.setattr(
|
| 353 |
+
ComposerReplicationTrainer, "_compute_sdpo_loss",
|
| 354 |
+
lambda self, model, inputs: torch.tensor(0.0),
|
| 355 |
+
raising=True,
|
| 356 |
+
)
|
| 357 |
+
monkeypatch.setattr(
|
| 358 |
+
ComposerReplicationTrainer, "_compute_trace_replay_loss",
|
| 359 |
+
lambda self, model, inputs: torch.tensor(0.0),
|
| 360 |
+
raising=True,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
for step in range(0, 35):
|
| 364 |
+
obj.state.global_step = step
|
| 365 |
+
obj._metrics["train"]["reward"].append(0.40 + 0.001 * step)
|
| 366 |
+
total = obj._compute_loss(model=object(), inputs={})
|
| 367 |
+
assert float(total.detach()) == pytest.approx(1.0)
|
| 368 |
+
|
| 369 |
+
# steps 0, 10, 20, 30 are the only cadence hits => exactly 4 guard folds.
|
| 370 |
+
assert folds["n"] == 4, f"expected 4 cadence folds, got {folds['n']}"
|
|
@@ -23,6 +23,9 @@ Complete reference for every public symbol in `composer_replication`. Source-of-
|
|
| 23 |
12. `composer_replication.diloco.serverless` (+ `.executor`, `.allreduce`, `.modal`, `.hf_jobs`, `.replica_entrypoint`)
|
| 24 |
13. `composer_replication.recipes.prime_rl.composer_loss`
|
| 25 |
14. `composer_replication.recipes.monarch.actors`
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
---
|
| 28 |
|
|
@@ -1498,6 +1501,358 @@ from composer_replication.recipes.monarch.actors import TrainerActor
|
|
| 1498 |
|
| 1499 |
---
|
| 1500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1501 |
## Notes on test coverage
|
| 1502 |
|
| 1503 |
Tested contracts (referenced spike/test paths):
|
|
@@ -1513,6 +1868,9 @@ Tested contracts (referenced spike/test paths):
|
|
| 1513 |
- `make_diloco_outer_loop` + sign convention: `spikes/008-streaming-diloco/tests/test_diloco_smoke.py`.
|
| 1514 |
- `ObjectStoreAllReduce`, `MockManager`, `LocalProcessExecutor`, `ReplicaHandle`, `ServerlessExecutor`, `replica_entrypoint.main`: `composer_replication/diloco/serverless/tests/test_serverless_local.py`, `test_serverless_diloco_integration.py`.
|
| 1515 |
- `recipes.prime_rl.composer_loss.loss_fn`: `composer_replication/recipes/prime_rl/tests/test_composer_loss.py`.
|
|
|
|
|
|
|
|
|
|
| 1516 |
|
| 1517 |
Untested-contract symbols (⚠️) and skeletons (🟡) are flagged inline above.
|
| 1518 |
|
|
|
|
| 23 |
12. `composer_replication.diloco.serverless` (+ `.executor`, `.allreduce`, `.modal`, `.hf_jobs`, `.replica_entrypoint`)
|
| 24 |
13. `composer_replication.recipes.prime_rl.composer_loss`
|
| 25 |
14. `composer_replication.recipes.monarch.actors`
|
| 26 |
+
15. `composer_replication.diloco.serverless` — cloud executors (`.eks`, `.sagemaker`)
|
| 27 |
+
16. `composer_replication.datagen.docker_sandbox`
|
| 28 |
+
17. `composer_replication.safety` (+ `.kill_switch`)
|
| 29 |
|
| 30 |
---
|
| 31 |
|
|
|
|
| 1501 |
|
| 1502 |
---
|
| 1503 |
|
| 1504 |
+
## 15. `composer_replication.diloco.serverless` — cloud executors
|
| 1505 |
+
|
| 1506 |
+
ADR-005 production cloud executors that implement the `ServerlessExecutor` Protocol (see §12) against real clouds. Both are the loud-success siblings of the `ModalExecutor` / `HFJobsExecutor` 🟡 skeletons: cross-replica communication is EXCLUSIVELY the S3 `ObjectStoreAllReduce` rendezvous, so the trainer / loss / DiLoCo math stay byte-for-byte identical regardless of backend. Both set `supports_inter_replica_network = False` (replicas rendezvous only through the object store) and both lazy-import their cloud SDK so `import composer_replication.diloco.serverless` is free of `kubernetes` / `boto3`.
|
| 1507 |
+
|
| 1508 |
+
**Extras**: `EKSExecutor` needs `kubernetes>=29` (`pip install -e .[eks]`); `SageMakerExecutor` needs `boto3>=1.34` (`pip install -e .[aws]`). The `[serverless]` extra pulls both (plus `fsspec` + `huggingface_hub` for the rendezvous backends). The SDK is required only at adapter-init / method time — when an api/client is injected (tests), a missing top-level package is tolerated.
|
| 1509 |
+
|
| 1510 |
+
### `class EKSExecutor` — `serverless.eks`
|
| 1511 |
+
|
| 1512 |
+
```python
|
| 1513 |
+
class EKSExecutor:
|
| 1514 |
+
backend_name = "eks"
|
| 1515 |
+
supports_inter_replica_network = False # S3 rendezvous only
|
| 1516 |
+
|
| 1517 |
+
def __init__(
|
| 1518 |
+
self,
|
| 1519 |
+
image: str,
|
| 1520 |
+
*,
|
| 1521 |
+
namespace: str = "default",
|
| 1522 |
+
service_account_name: str | None = None,
|
| 1523 |
+
node_selector: dict[str, str] | None = None,
|
| 1524 |
+
tolerations: list[Any] | None = None,
|
| 1525 |
+
runtime_class_name: str | None = None,
|
| 1526 |
+
command: list[str] | None = None,
|
| 1527 |
+
cpu_request: str = "4",
|
| 1528 |
+
memory_request: str = "16Gi",
|
| 1529 |
+
ttl_seconds_after_finished: int = 3600,
|
| 1530 |
+
backoff_limit: int = 0,
|
| 1531 |
+
gpu_resource_key: str = "nvidia.com/gpu",
|
| 1532 |
+
run_id: str | None = None,
|
| 1533 |
+
batch_api: Any = None,
|
| 1534 |
+
core_api: Any = None,
|
| 1535 |
+
) -> None: ...
|
| 1536 |
+
# implements ServerlessExecutor protocol (launch_replicas/poll/stream_logs/cancel/collect)
|
| 1537 |
+
```
|
| 1538 |
+
|
| 1539 |
+
Run N DiLoCo replicas as a **single Kubernetes Indexed Job** on EKS (`completions == parallelism == n_replicas`, `completionMode="Indexed"`). The control plane assigns each pod a `JOB_COMPLETION_INDEX` 0..N-1 which IS the rank; the executor bridges it to the repo entrypoint's `REPLICA_RANK` env var via the downward API, so `replica_entrypoint` works unchanged. `launch_replicas` creates ONE Job but still returns N `ReplicaHandle`s (`handles[i].rank == i`) sharing the same `job_name`/`namespace` (gang semantics).
|
| 1540 |
+
|
| 1541 |
+
**Constructor parameters**
|
| 1542 |
+
|
| 1543 |
+
| Name | Type | Default | Meaning |
|
| 1544 |
+
|---|---|---|---|
|
| 1545 |
+
| `image` | `str` | — | Container image with `composer_replication` installed; runs the replica entrypoint. |
|
| 1546 |
+
| `namespace` | `str` (kw-only) | `"default"` | k8s namespace for the Job. |
|
| 1547 |
+
| `service_account_name` | `str \| None` (kw-only) | `None` | ServiceAccount referenced on the PodSpec for IRSA / EKS Pod Identity S3 access. REFERENCED only — never created. |
|
| 1548 |
+
| `node_selector` | `dict[str,str] \| None` (kw-only) | `None` | Extra node selector merged under the GPU node selector. |
|
| 1549 |
+
| `tolerations` | `list[Any] \| None` (kw-only) | `None` | PodSpec tolerations; a `nvidia.com/gpu` NoSchedule toleration is auto-added on GPU requests when none supplied. |
|
| 1550 |
+
| `runtime_class_name` | `str \| None` (kw-only) | `None` | Pre-existing RuntimeClass (`"gvisor"` / `"kata"`). Combining with `gpu` is advanced/unverified (see source warning). |
|
| 1551 |
+
| `command` | `list[str] \| None` (kw-only) | `None` ⇒ `["python","-m","composer_replication.diloco.serverless.replica_entrypoint"]` | Container command. |
|
| 1552 |
+
| `cpu_request` / `memory_request` | `str` (kw-only) | `"4"` / `"16Gi"` | PodSpec resource requests. |
|
| 1553 |
+
| `ttl_seconds_after_finished` | `int` (kw-only) | `3600` | Auto-delete the finished Job (cascading) after this many seconds. |
|
| 1554 |
+
| `backoff_limit` | `int` (kw-only) | `0` | Job retry budget (fail-fast; NOT the k8s default of 6). |
|
| 1555 |
+
| `gpu_resource_key` | `str` (kw-only) | `"nvidia.com/gpu"` | GPU resource key. |
|
| 1556 |
+
| `run_id` | `str \| None` (kw-only) | `None` ⇒ `"diloco"` | Prefix baked into the generated Job name. |
|
| 1557 |
+
| `batch_api` / `core_api` | `Any` (kw-only) | `None` | DI'd `BatchV1Api` / `CoreV1Api`; lazily built (in-cluster then kube-config) when `None`. Tests inject mocks. |
|
| 1558 |
+
|
| 1559 |
+
**`ServerlessExecutor` Protocol methods** (see §12 for the full Protocol)
|
| 1560 |
+
|
| 1561 |
+
- `launch_replicas(n_replicas, entrypoint, entrypoint_args, *, gpu=None, timeout=3600) -> list[ReplicaHandle]` — creates ONE Indexed Job; `entrypoint` is ignored (k8s runs the fixed container command), scalar `entrypoint_args` (e.g. `rendezvous_uri`) are passed as upper-cased literal env vars, `gpu` maps to a `nvidia.com/gpu` limit + node selector via the `_GPU_SPEC_TABLE` (`"A100"`/`"H100"`/`"A10G"`/`"T4"`), and `timeout` becomes the Job's `active_deadline_seconds` hard wall-clock kill.
|
| 1562 |
+
- `poll(handle) -> str` — reads the single Job status and maps THIS rank: `rank in completed_indexes` ⇒ `"succeeded"`, `rank in failed_indexes` ⇒ `"failed"`, whole-Job `Failed` condition ⇒ `"failed"`, `active > 0` ⇒ `"running"`, else `"pending"`; a 404 (Job gone) ⇒ `"cancelled"`.
|
| 1563 |
+
- `stream_logs(handle, *, n_lines=200) -> str` — finds the pod by completion-index annotation/label (or the `<job>-<rank>-` name prefix), tails its log; returns a placeholder string (never raises) when the pod has not started.
|
| 1564 |
+
- `cancel(handle) -> None` — deletes the WHOLE shared Indexed Job (`propagation_policy="Background"` so pods are cascadingly deleted, not orphaned). Intentional gang teardown; idempotent (404 swallowed, unknown handle is a no-op).
|
| 1565 |
+
- `collect(handles, *, timeout=None) -> list[dict]` — polls (sleeping between, status is eventually consistent) until every rank is terminal or the deadline; per-rank dict is `{"rank","status","exit_code","error","job_name"}` (`exit_code` 0/1/`None`).
|
| 1566 |
+
|
| 1567 |
+
**Raises** `RuntimeError` at construction if the `kubernetes` client is absent AND no api was injected; `ValueError` if `n_replicas < 1`.
|
| 1568 |
+
|
| 1569 |
+
```python
|
| 1570 |
+
from composer_replication.diloco.serverless.eks import EKSExecutor
|
| 1571 |
+
ex = EKSExecutor(image="ghcr.io/me/diloco:latest",
|
| 1572 |
+
service_account_name="diloco-s3")
|
| 1573 |
+
handles = ex.launch_replicas(
|
| 1574 |
+
n_replicas=4,
|
| 1575 |
+
entrypoint="", # ignored on k8s
|
| 1576 |
+
entrypoint_args={"rendezvous_uri": "s3://bucket/run/",
|
| 1577 |
+
"trainer_module": "my.trainer", "world_size": 4},
|
| 1578 |
+
gpu="H100",
|
| 1579 |
+
)
|
| 1580 |
+
results = ex.collect(handles, timeout=3600)
|
| 1581 |
+
```
|
| 1582 |
+
|
| 1583 |
+
### `class SageMakerExecutor` — `serverless.sagemaker`
|
| 1584 |
+
|
| 1585 |
+
```python
|
| 1586 |
+
class SageMakerExecutor:
|
| 1587 |
+
backend_name = "sagemaker"
|
| 1588 |
+
supports_inter_replica_network = False # S3 rendezvous only
|
| 1589 |
+
|
| 1590 |
+
def __init__(
|
| 1591 |
+
self,
|
| 1592 |
+
*,
|
| 1593 |
+
role_arn: str,
|
| 1594 |
+
image_uri: str,
|
| 1595 |
+
output_s3_path: str,
|
| 1596 |
+
instance_type: str = "ml.g5.2xlarge",
|
| 1597 |
+
cpu_instance_type: str = "ml.m5.xlarge",
|
| 1598 |
+
volume_size_gb: int = 100,
|
| 1599 |
+
run_id: str | None = None,
|
| 1600 |
+
region: str | None = None,
|
| 1601 |
+
sagemaker_client: Any = None,
|
| 1602 |
+
logs_client: Any = None,
|
| 1603 |
+
) -> None: ...
|
| 1604 |
+
# implements ServerlessExecutor protocol
|
| 1605 |
+
```
|
| 1606 |
+
|
| 1607 |
+
Run replicas as **N independent single-instance SageMaker Training Jobs** (NOT one multi-instance job — that would couple replicas through SageMaker's intra-job NCCL/MPI fabric and break the "each replica syncs only through S3" design). Rank goes through the per-job `Environment` map (`REPLICA_RANK=i` / `WORLD_SIZE=N`), so `replica_entrypoint` reads it unchanged. Pins `EnableNetworkIsolation=False` (never a knob) — `True` would sever the container's S3 access and dead-lock the allreduce poll loop.
|
| 1608 |
+
|
| 1609 |
+
**Constructor parameters** (all kw-only)
|
| 1610 |
+
|
| 1611 |
+
| Name | Type | Default | Meaning |
|
| 1612 |
+
|---|---|---|---|
|
| 1613 |
+
| `role_arn` | `str` | — | IAM execution role SageMaker assumes; must grant S3 to the rendezvous + output buckets (the boto3 analog of EKS IRSA). Caller needs `iam:PassRole`. |
|
| 1614 |
+
| `image_uri` | `str` | — | ECR training-container image. The executor also passes `ContainerEntrypoint` explicitly so a generic image works. |
|
| 1615 |
+
| `output_s3_path` | `str` | — | `s3://...` prefix for `OutputDataConfig.S3OutputPath`. |
|
| 1616 |
+
| `instance_type` | `str` | `"ml.g5.2xlarge"` | Default instance type when `gpu` is unmapped. |
|
| 1617 |
+
| `cpu_instance_type` | `str` | `"ml.m5.xlarge"` | Instance type used when `gpu=None` (CPU smoke). |
|
| 1618 |
+
| `volume_size_gb` | `int` | `100` | `ResourceConfig.VolumeSizeInGB` per job. |
|
| 1619 |
+
| `run_id` | `str \| None` | `None` ⇒ random token | Prefix for generated training-job names. |
|
| 1620 |
+
| `region` | `str \| None` | `None` | AWS region for the lazy boto3 clients (`None` ⇒ ambient default). |
|
| 1621 |
+
| `sagemaker_client` | `Any` | `None` | Inject a pre-built `boto3.client("sagemaker")` (or a mock); built lazily otherwise. |
|
| 1622 |
+
| `logs_client` | `Any` | `None` | Inject a pre-built `boto3.client("logs")`; built lazily on first `stream_logs`. |
|
| 1623 |
+
|
| 1624 |
+
**`ServerlessExecutor` Protocol methods**
|
| 1625 |
+
|
| 1626 |
+
- `launch_replicas(n_replicas, entrypoint, entrypoint_args, *, gpu=None, timeout=3600) -> list[ReplicaHandle]` — submits N independent jobs; `entrypoint` is ignored (container command is baked / passed explicitly). `entrypoint_args` MUST include `rendezvous_uri` (`s3://...`) and `trainer_module`; optional `trainer_fn` (default `"train"`) and `trainer_kwargs` (dict, JSON-encoded into `ContainerArguments`). `gpu` maps to an instance type via `_GPU_INSTANCE_MAP` (`"A100"`/`"H100"`/`"H200"`/`"B200"`/`"L40S"`/`"A10G"`/`"L4"`; a literal `ml.*` string is honoured); `timeout` ⇒ `StoppingCondition.MaxRuntimeInSeconds`. On mid-launch failure it best-effort stops already-launched siblings then raises.
|
| 1627 |
+
- `poll(handle) -> str` — maps `describe_training_job`'s `TrainingJobStatus` via `_STATUS_MAP` (`InProgress`⇒`running`, `Completed`⇒`succeeded`, `Failed`⇒`failed`, `Stopping`⇒`running`, `Stopped`⇒`cancelled`); refines `InProgress`⇒`"pending"` while still queued (`SecondaryStatus` in `_PENDING_SECONDARY`); a vanished job (`ResourceNotFound`) ⇒ `"cancelled"`.
|
| 1628 |
+
- `stream_logs(handle, *, n_lines=200) -> str` — discovers the CloudWatch stream under `/aws/sagemaker/TrainingJobs` by `<job-name>/` prefix and tails it; falls back to a CloudWatch console URL on any error.
|
| 1629 |
+
- `cancel(handle) -> None` — best-effort `stop_training_job` (swallows `ResourceNotFound` / already-terminal `ValidationException`).
|
| 1630 |
+
- `collect(handles, *, timeout=None) -> list[dict]` — polls per handle until terminal (`Completed`/`Failed`/`Stopped`) or the shared deadline; results aligned to input order; dict is `{"rank","status","exit_code","error","result","training_job_name"}` (`result` is the `S3ModelArtifacts` path).
|
| 1631 |
+
|
| 1632 |
+
**Raises** `RuntimeError` if boto3 is absent and no client was injected; `ValueError` if `n_replicas < 1` or `entrypoint_args` lacks `rendezvous_uri` / `trainer_module`.
|
| 1633 |
+
|
| 1634 |
+
```python
|
| 1635 |
+
from composer_replication.diloco.serverless.sagemaker import SageMakerExecutor
|
| 1636 |
+
ex = SageMakerExecutor(
|
| 1637 |
+
role_arn="arn:aws:iam::123:role/sm-exec",
|
| 1638 |
+
image_uri="123.dkr.ecr.us-east-1.amazonaws.com/diloco:latest",
|
| 1639 |
+
output_s3_path="s3://bucket/out/", region="us-east-1")
|
| 1640 |
+
handles = ex.launch_replicas(
|
| 1641 |
+
n_replicas=2, entrypoint="",
|
| 1642 |
+
entrypoint_args={"rendezvous_uri": "s3://bucket/run/",
|
| 1643 |
+
"trainer_module": "my.trainer"},
|
| 1644 |
+
gpu="A100")
|
| 1645 |
+
results = ex.collect(handles)
|
| 1646 |
+
```
|
| 1647 |
+
|
| 1648 |
+
---
|
| 1649 |
+
|
| 1650 |
+
## 16. `composer_replication.datagen.docker_sandbox`
|
| 1651 |
+
|
| 1652 |
+
ADR-010 §3 hardened container backend for the FeatureDeletion (FD) env. `DockerSandbox` is a drop-in implementation of the `Sandbox` Protocol (`composer_replication.datagen.sandbox.Sandbox`) that runs the agent's tool calls and the verifiable test command inside an ephemeral, locked-down Docker container instead of a raw host subprocess. It is the production execution path for genuinely UNTRUSTED model-generated code (the `LocalSubprocessSandbox` sibling runs in the host process and is NOT a host-security boundary).
|
| 1653 |
+
|
| 1654 |
+
**Extra**: needs `docker>=7` (`pip install -e .[datagen]` or `pip install docker`). The SDK is **lazy-imported inside methods** so the pure-Python core and the `FakeSandbox` path never require it; a clear `RuntimeError` is raised if the SDK or the daemon is absent.
|
| 1655 |
+
|
| 1656 |
+
### The `Sandbox` Protocol — `datagen.sandbox`
|
| 1657 |
+
|
| 1658 |
+
```python
|
| 1659 |
+
@runtime_checkable
|
| 1660 |
+
class Sandbox(Protocol):
|
| 1661 |
+
def boot(self, image: str) -> None: ...
|
| 1662 |
+
def exec(self, action: dict) -> str: ...
|
| 1663 |
+
def run_tests(self, test_command: str, tests: tuple[str, ...]) -> TestRunResult: ...
|
| 1664 |
+
def trajectory(self) -> list[dict]: ...
|
| 1665 |
+
def is_command_allowed(self, command: str) -> bool: ...
|
| 1666 |
+
```
|
| 1667 |
+
|
| 1668 |
+
Structural protocol every FD execution backend implements (`DockerSandbox`, `LocalSubprocessSandbox`, `FakeSandbox`). `boot` prepares the execution environment, `exec` runs one tool-call `action` dict (`{"command": ...}`) and returns combined stdout/stderr, `run_tests` runs the verifiable pytest command over the given node ids and returns a `TestRunResult`, `trajectory` returns the recorded action list, `is_command_allowed` is the first-token denylist check.
|
| 1669 |
+
|
| 1670 |
+
### `class DockerSandbox`
|
| 1671 |
+
|
| 1672 |
+
```python
|
| 1673 |
+
@dataclass
|
| 1674 |
+
class DockerSandbox:
|
| 1675 |
+
workdir: str
|
| 1676 |
+
runtime: str | None = None
|
| 1677 |
+
mem_limit: str = "1g"
|
| 1678 |
+
memswap_limit: str = "1g"
|
| 1679 |
+
pids_limit: int = 256
|
| 1680 |
+
nano_cpus: int = 2_000_000_000 # 2 CPUs
|
| 1681 |
+
user: str = "1000:1000"
|
| 1682 |
+
container_workdir: str = "/work"
|
| 1683 |
+
tmpfs_size: str = "64m"
|
| 1684 |
+
exec_timeout_s: int = 600
|
| 1685 |
+
keep_root_writable: bool = False
|
| 1686 |
+
|
| 1687 |
+
def container_kwargs(self, image: str) -> dict: ...
|
| 1688 |
+
# Sandbox Protocol:
|
| 1689 |
+
def boot(self, image: str) -> None: ...
|
| 1690 |
+
def exec(self, action: dict) -> str: ...
|
| 1691 |
+
def run_tests(self, test_command: str, tests: tuple[str, ...]) -> TestRunResult: ...
|
| 1692 |
+
def trajectory(self) -> list[dict]: ...
|
| 1693 |
+
def is_command_allowed(self, command: str) -> bool: ...
|
| 1694 |
+
def close(self) -> None: ...
|
| 1695 |
+
@staticmethod
|
| 1696 |
+
def reap_leaked(client=None) -> int: ...
|
| 1697 |
+
```
|
| 1698 |
+
|
| 1699 |
+
Hardened ephemeral-container `Sandbox`. The lockdown recipe (CIS Docker 5.x + gVisor guidance): `network_disabled=True` + `network_mode="none"` (no egress — the reward-hack exfil control), `read_only=True` root fs + small `tmpfs` for `/tmp`, workdir bind-mounted RW at `/work`, `cap_drop=["ALL"]` + `security_opt=["no-new-privileges:true"]`, non-root `user`, `pids_limit` (fork-bomb guard) + `mem_limit==memswap_limit` (OOM, no swap) + `nano_cpus` (CPU quota). The PRIMARY reward-hack control is the host-side `scrub_tree(workdir)` run in `boot()` BEFORE the container starts (the bind mount is shared, so host-pre-boot scrubbing == in-container scrubbing); the command denylist is cheap defense-in-depth, not the wall.
|
| 1700 |
+
|
| 1701 |
+
**Constructor fields**
|
| 1702 |
+
|
| 1703 |
+
| Field | Type | Default | Meaning |
|
| 1704 |
+
|---|---|---|---|
|
| 1705 |
+
| `workdir` | `str` | — | Host path to the materialized repo; bind-mounted RW at `/work` and scrubbed on the host before boot (PRIMARY control). Must exist by `boot()` time. |
|
| 1706 |
+
| `runtime` | `str \| None` | `None` ⇒ daemon default (runc) | `"runsc"` (gVisor) for untrusted model code; requires host `sudo runsc install` + dockerd restart. Gate with `runsc_available()`. |
|
| 1707 |
+
| `mem_limit` / `memswap_limit` | `str` | `"1g"` / `"1g"` | OOM guard; equal values forbid swap. |
|
| 1708 |
+
| `pids_limit` | `int` | `256` | Fork-bomb guard. |
|
| 1709 |
+
| `nano_cpus` | `int` | `2_000_000_000` | CPU quota in 1e-9 CPUs (2 CPUs). |
|
| 1710 |
+
| `user` | `str` | `"1000:1000"` | Non-root uid:gid the agent code runs as. |
|
| 1711 |
+
| `container_workdir` | `str` | `"/work"` | Bind-mount target + working dir. |
|
| 1712 |
+
| `tmpfs_size` | `str` | `"64m"` | Size of the `/tmp` tmpfs scratch. |
|
| 1713 |
+
| `exec_timeout_s` | `int` | `600` | Wall-clock cap injected via coreutils `timeout` (exec_run has no timeout param — docker-py #2651). |
|
| 1714 |
+
| `keep_root_writable` | `bool` | `False` | Escape hatch if read-only fs breaks tooling. |
|
| 1715 |
+
|
| 1716 |
+
**`Sandbox` Protocol methods**
|
| 1717 |
+
|
| 1718 |
+
- `boot(image) -> None` — scrubs the host workdir (primary control), reaps leaked siblings, then starts the hardened container. **Raises** `RuntimeError` if the image is not found locally (the container is `--network none`, so it cannot pull) or on a Docker API error.
|
| 1719 |
+
- `exec(action) -> str` — records the `action`, denylist-checks its first token, runs the command in the live container (combined stdout/stderr, non-UTF-8 bytes decoded with `errors="replace"`). Returns an `ERROR:` string for a denied command.
|
| 1720 |
+
- `run_tests(test_command, tests) -> TestRunResult` — `shlex.quote`s each node id, runs the verifiable command, parses pass/fail conservatively (PASSED-or-rc0 ⇒ passed; `errors during collection` ⇒ `collected_ok=False`).
|
| 1721 |
+
- `trajectory() -> list[dict]` — copy of the recorded action list.
|
| 1722 |
+
- `is_command_allowed(command) -> bool` — first-token denylist (`SANDBOX_DENYLIST`); not a boundary on its own.
|
| 1723 |
+
|
| 1724 |
+
**Lifecycle**: `close()` force-removes the container (idempotent, swallows errors); `reap_leaked(client=None) -> int` (staticmethod) sweeps containers labelled `composer_replication=datagen` and returns the count removed. Also a context manager (`__enter__` / `__exit__` ⇒ `close()`).
|
| 1725 |
+
|
| 1726 |
+
**Module helpers**: `runsc_available(client=None) -> bool` (True iff the gVisor `runsc` runtime is registered with the daemon — gate any runsc behavior on this); `_require_docker()` / `_make_client()` (lazy SDK import + daemon ping, each raising a clear `RuntimeError`).
|
| 1727 |
+
|
| 1728 |
+
```python
|
| 1729 |
+
from composer_replication.datagen.docker_sandbox import DockerSandbox, runsc_available
|
| 1730 |
+
runtime = "runsc" if runsc_available() else None
|
| 1731 |
+
with DockerSandbox(workdir="/tmp/repo", runtime=runtime) as sb:
|
| 1732 |
+
sb.boot("python:3.12-slim")
|
| 1733 |
+
out = sb.exec({"command": "python -c 'print(1+1)'"})
|
| 1734 |
+
res = sb.run_tests("python -m pytest -q", ("tests/test_x.py::test_a",))
|
| 1735 |
+
```
|
| 1736 |
+
|
| 1737 |
+
---
|
| 1738 |
+
|
| 1739 |
+
## 17. `composer_replication.safety` & `composer_replication.safety.kill_switch`
|
| 1740 |
+
|
| 1741 |
+
ADR-015 run-level collapse safeguard — the #2 collapse safety net for the self-evolving RL flywheel. The per-task controls live in `composer_replication.datagen` (4-gate validator, `HackMonitor` provenance, sandbox denylist); this package adds the missing ACROSS-GENERATION / run-level control that watches in-loop (proxy) reward against a disjoint held-out (real) eval and HALTS the run when collapse / reward-hacking is caught in the act. Pure-Python: no torch, no cloud deps; fully CPU-testable (`kl_to_init` is just a float the caller computes upstream).
|
| 1742 |
+
|
| 1743 |
+
Public surface (`composer_replication.safety.__all__`): `HeldOutGuard`, `TripwireStatus`, `CollapseStopError`, `kl_token_trust_filter`.
|
| 1744 |
+
|
| 1745 |
+
### `class TripwireStatus` — `safety.kill_switch`
|
| 1746 |
+
|
| 1747 |
+
```python
|
| 1748 |
+
@dataclass(frozen=True)
|
| 1749 |
+
class TripwireStatus:
|
| 1750 |
+
fire: bool
|
| 1751 |
+
reason: str
|
| 1752 |
+
step: int
|
| 1753 |
+
proxy_real_gap: float
|
| 1754 |
+
in_loop_ema: float
|
| 1755 |
+
heldout_ema: float
|
| 1756 |
+
kl_ema: float | None = None
|
| 1757 |
+
|
| 1758 |
+
@property
|
| 1759 |
+
def halt(self) -> bool: ... # documented alias for `fire`
|
| 1760 |
+
```
|
| 1761 |
+
|
| 1762 |
+
Structured verdict returned by every `HeldOutGuard.update(...)`. `fire` (alias `halt`) ⇒ the run should HALT; `reason` is the human-readable WHY (empty when not firing); `proxy_real_gap` is the RSI "Hacking Gap" (in-loop gain − held-out gain since baseline); `*_ema` are the denoised metric EMAs.
|
| 1763 |
+
|
| 1764 |
+
### `class CollapseStopError(RuntimeError)` — `safety.kill_switch`
|
| 1765 |
+
|
| 1766 |
+
```python
|
| 1767 |
+
class CollapseStopError(RuntimeError):
|
| 1768 |
+
def __init__(self, status: TripwireStatus) -> None: ...
|
| 1769 |
+
status: TripwireStatus
|
| 1770 |
+
```
|
| 1771 |
+
|
| 1772 |
+
Typed exception for exception-based trainer control flow; carries the fired `TripwireStatus` for logging. Raised (optionally) via `HeldOutGuard.raise_if_fired(...)`.
|
| 1773 |
+
|
| 1774 |
+
### `class HeldOutGuard` — `safety.kill_switch`
|
| 1775 |
+
|
| 1776 |
+
```python
|
| 1777 |
+
@dataclass
|
| 1778 |
+
class HeldOutGuard:
|
| 1779 |
+
kl_hard_stop: float = 0.08 # nats/token; top of GRPO "good" band
|
| 1780 |
+
max_proxy_real_gap: float = 0.10 # absolute Hacking-Gap blowout ceiling
|
| 1781 |
+
min_steps: int = 20 # no fire before this many updates
|
| 1782 |
+
decline_patience: int = 3 # consecutive held-out declines to fire (a)
|
| 1783 |
+
ema_alpha: float = 0.9 # EMA weight on the PRIOR (0.9 => slow)
|
| 1784 |
+
rise_eps: float = 1e-4 # min EMA delta to count as rising/declining
|
| 1785 |
+
|
| 1786 |
+
def update(self, round_idx: int, in_loop_reward: float, heldout_score: float,
|
| 1787 |
+
kl_to_init: float | None = None, entropy: float | None = None,
|
| 1788 |
+
reward_std: float | None = None) -> TripwireStatus: ...
|
| 1789 |
+
def should_halt(self) -> bool: ...
|
| 1790 |
+
@property
|
| 1791 |
+
def last_status(self) -> TripwireStatus | None: ...
|
| 1792 |
+
def raise_if_fired(self, status: TripwireStatus | None = None) -> None: ...
|
| 1793 |
+
def proxy_real_gap(self) -> float: ...
|
| 1794 |
+
def calibrate_kl_threshold(self, baseline_kls: list[float], factor: float = 3.0) -> float: ...
|
| 1795 |
+
```
|
| 1796 |
+
|
| 1797 |
+
Stateful across-generation collapse / reward-hacking kill-switch. Call `update(...)` once per checkpoint (the same cadence as `DifficultyCurriculum.update`); it folds each metric into a denoised EMA and returns a `TripwireStatus`.
|
| 1798 |
+
|
| 1799 |
+
**Fires (`fire=True`) when ANY of three conditions** (none before `min_steps`, which guards early-run noise):
|
| 1800 |
+
|
| 1801 |
+
- **(a) collapse-caught-in-the-act** — the in-loop reward EMA is RISING while the held-out score EMA has DECLINED for `>= decline_patience` consecutive checkpoints (the canonical reward-hacking signature: proxy up, real down).
|
| 1802 |
+
- **(b) KL hard stop** — the `kl_to_init` EMA exceeds `kl_hard_stop` (default **0.08 nats/token**, the top of the GRPO "good progression" band). Checked first (cheapest unambiguous breach).
|
| 1803 |
+
- **(c) proxy-real gap blowout** — the Hacking Gap (proxy gain − real gain since baseline) widens beyond `max_proxy_real_gap`, catching a fast single-generation divergence even before the full decline window.
|
| 1804 |
+
|
| 1805 |
+
Once fired, the verdict is **latched** — every subsequent `update` keeps `fire=True` so a transient recovery cannot silently un-halt the run.
|
| 1806 |
+
|
| 1807 |
+
**Constructor thresholds**
|
| 1808 |
+
|
| 1809 |
+
| Field | Type | Default | Meaning |
|
| 1810 |
+
|---|---|---|---|
|
| 1811 |
+
| `kl_hard_stop` | `float` | `0.08` | Per-token KL(policy‖init) hard-stop ceiling, nats/token (condition (b)). Must be > 0. |
|
| 1812 |
+
| `max_proxy_real_gap` | `float` | `0.10` | Absolute Hacking-Gap blowout ceiling (condition (c)). |
|
| 1813 |
+
| `min_steps` | `int` | `20` | No tripwire fires before this many `update` calls. |
|
| 1814 |
+
| `decline_patience` | `int` | `3` | Consecutive held-out declines (while in-loop rises) to fire (a). Must be >= 1. |
|
| 1815 |
+
| `ema_alpha` | `float` | `0.9` | EMA weight on the PRIOR (0.9 ⇒ slow). Must be in `[0, 1)`. |
|
| 1816 |
+
| `rise_eps` | `float` | `1e-4` | Min EMA delta to count as "rising" / "declining". |
|
| 1817 |
+
|
| 1818 |
+
**API**
|
| 1819 |
+
|
| 1820 |
+
- `update(round_idx, in_loop_reward, heldout_score, kl_to_init=None, entropy=None, reward_std=None) -> TripwireStatus` — fold one checkpoint's metrics and return the verdict. `round_idx` is for logging only (the internal counter `_n` drives `min_steps`). `kl_to_init` is **token-mean KL in nats/token** (this repo's `token_mean_kl` convention) — do NOT pass a sequence-summed KL (it will fire instantly). `entropy` / `reward_std` are tracked + exposed but not hard gates.
|
| 1821 |
+
- `should_halt() -> bool` — True iff the most recent `update` fired. **Idempotent** (does not advance EMA state).
|
| 1822 |
+
- `last_status -> TripwireStatus | None` (property) — the most recent verdict, or `None` before the first `update`.
|
| 1823 |
+
- `raise_if_fired(status=None) -> None` — convert a fired verdict (the passed status, or `last_status`) into a `CollapseStopError`; a no-op otherwise. For exception-based loops.
|
| 1824 |
+
- `proxy_real_gap() -> float` — the RSI Hacking Gap (EMA-minus-baseline, both since run start); `0.0` before the first `update`.
|
| 1825 |
+
- `calibrate_kl_threshold(baseline_kls, factor=3.0) -> float` — set `kl_hard_stop` from early-run baseline KLs (`factor` × mean). SAFETY CLAMP: only ever TIGHTENS (`min(factor*mean, current)`), never loosens past the documented band. **Raises** `ValueError` on empty `baseline_kls`.
|
| 1826 |
+
|
| 1827 |
+
**Raises** `ValueError` at construction if `ema_alpha` ∉ `[0, 1)`, `kl_hard_stop <= 0`, or `decline_patience < 1`.
|
| 1828 |
+
|
| 1829 |
+
> **HeldoutSplit discipline (design-of-record).** `heldout_score` must come from a DISJOINT held-out eval pool — REAL tasks the generator NEVER trains on. If the held-out set drifts with the train set, the proxy-real gap signal is meaningless. See ADR-015 and the `safety.holdout` design notes referenced from the module docstring.
|
| 1830 |
+
|
| 1831 |
+
```python
|
| 1832 |
+
from composer_replication.safety import HeldOutGuard
|
| 1833 |
+
guard = HeldOutGuard(kl_hard_stop=0.08)
|
| 1834 |
+
for rnd in range(num_generations):
|
| 1835 |
+
status = guard.update(rnd, in_loop_reward=r_proxy, heldout_score=r_real,
|
| 1836 |
+
kl_to_init=token_mean_kl_value)
|
| 1837 |
+
if status.fire: # or: guard.should_halt()
|
| 1838 |
+
guard.raise_if_fired(status) # -> CollapseStopError
|
| 1839 |
+
```
|
| 1840 |
+
|
| 1841 |
+
### `kl_token_trust_filter(logratio_sq_half, threshold=0.08) -> bool` — `safety.kill_switch`
|
| 1842 |
+
|
| 1843 |
+
```python
|
| 1844 |
+
def kl_token_trust_filter(logratio_sq_half: float, threshold: float = 0.08) -> bool
|
| 1845 |
+
```
|
| 1846 |
+
|
| 1847 |
+
Per-token KL trust-region mask mirroring torchrl's GRPO "KL-Mask". The caller computes `0.5 * (log π/π_ref)**2` (the Schulman k2 per-token KL estimator, nats); this returns `True` when the token should be MASKED OUT (its KL contribution exceeds `threshold`). Pulls no torch into the module — wire it into a loss downstream.
|
| 1848 |
+
|
| 1849 |
+
```python
|
| 1850 |
+
from composer_replication.safety import kl_token_trust_filter
|
| 1851 |
+
mask_out = kl_token_trust_filter(0.5 * logratio**2, threshold=0.08)
|
| 1852 |
+
```
|
| 1853 |
+
|
| 1854 |
+
---
|
| 1855 |
+
|
| 1856 |
## Notes on test coverage
|
| 1857 |
|
| 1858 |
Tested contracts (referenced spike/test paths):
|
|
|
|
| 1868 |
- `make_diloco_outer_loop` + sign convention: `spikes/008-streaming-diloco/tests/test_diloco_smoke.py`.
|
| 1869 |
- `ObjectStoreAllReduce`, `MockManager`, `LocalProcessExecutor`, `ReplicaHandle`, `ServerlessExecutor`, `replica_entrypoint.main`: `composer_replication/diloco/serverless/tests/test_serverless_local.py`, `test_serverless_diloco_integration.py`.
|
| 1870 |
- `recipes.prime_rl.composer_loss.loss_fn`: `composer_replication/recipes/prime_rl/tests/test_composer_loss.py`.
|
| 1871 |
+
- `EKSExecutor`, `SageMakerExecutor` (§15): `composer_replication/diloco/serverless/tests/` (DI'd `batch_api`/`core_api` / `sagemaker_client` mocks; no live cloud).
|
| 1872 |
+
- `DockerSandbox` (§16) — `container_kwargs` lockdown config asserted without a live daemon; daemon-gated paths in `composer_replication/datagen/tests/`.
|
| 1873 |
+
- `HeldOutGuard`, `TripwireStatus`, `CollapseStopError`, `kl_token_trust_filter` (§17): `composer_replication/safety/tests/` (pure-Python; CPU-only).
|
| 1874 |
|
| 1875 |
Untested-contract symbols (⚠️) and skeletons (🟡) are flagged inline above.
|
| 1876 |
|
|
@@ -75,6 +75,8 @@ Goal-driven systematic resolution of every pending item. This doc is the live au
|
|
| 75 |
| R11 | Flaky test `spikes/006-real-hf-model-smoke/tests/test_strict.py::test_alternating_batches_loss_decreases` — fails under CPU contention (full suite w/ concurrent pytest + Docker), PASSES in isolation (verified 3x). Loss-trend assertion is timing/noise-sensitive. Pin seed / widen tolerance / mark flaky. Pre-existing, not a Wave-2 regression. | LOW | OPEN |
|
| 76 |
| R12 | B7-complete ✅ (top-level `__all__` now includes the 3 factories) + B4-complete ✅ (the 4 surviving "115" claims → 266/62). | — | DONE |
|
| 77 |
|
|
|
|
|
|
|
| 78 |
Sandbox refactor verdict: **clean** (no regression to LocalSubprocessSandbox/FeatureDeletionEnv).
|
| 79 |
|
| 80 |
## Wave plan
|
|
|
|
| 75 |
| R11 | Flaky test `spikes/006-real-hf-model-smoke/tests/test_strict.py::test_alternating_batches_loss_decreases` — fails under CPU contention (full suite w/ concurrent pytest + Docker), PASSES in isolation (verified 3x). Loss-trend assertion is timing/noise-sensitive. Pin seed / widen tolerance / mark flaky. Pre-existing, not a Wave-2 regression. | LOW | OPEN |
|
| 76 |
| R12 | B7-complete ✅ (top-level `__all__` now includes the 3 factories) + B4-complete ✅ (the 4 surviving "115" claims → 266/62). | — | DONE |
|
| 77 |
|
| 78 |
+
**Wave 3 — DONE (Phase-7 reconciliation):** R1 ✅ (HeldOutGuard wired into ComposerReplicationTrainer — optional, OFF by default, soft/hard stop; + integration test), R2 ✅ (HeldoutSplit disjointness enforcer `safety/holdout.py` + 10 tests), R3 ✅ (EKS entrypoint contract bug fixed — `replica_entrypoint.__main__` now resolves from env OR argv; proven end-to-end with a pure-env invocation), R4 ✅ (calibrate_kl_threshold rejects factor<=0/negative-baseline + positive floor), R7 ✅ (API_REFERENCE §15-17: EKS/SageMaker/DockerSandbox/safety), R8 ✅ (ADR-015 authored + indexed), R10 ✅ (path-(c) divergence-rate test). R12 ✅ (B4/B7 complete). DEFERRED-LOW: R5 (cancel exception-narrowing) + R6 (EKS collect result-key) — stale-base worktree casualties, tracked, LOW severity. R11 (spike-006 flaky-under-contention) — pre-existing, tracked.
|
| 79 |
+
|
| 80 |
Sandbox refactor verdict: **clean** (no regression to LocalSubprocessSandbox/FeatureDeletionEnv).
|
| 81 |
|
| 82 |
## Wave plan
|
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
status: accepted
|
| 3 |
+
date: 2026-06-08
|
| 4 |
+
deciders: [Codeseys, ARIA]
|
| 5 |
+
builds-on: [ADR-010 (FeatureDeletion datagen — per-task controls), ADR-012 (curriculum + provenance review findings)]
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# ADR-015: Held-out disjoint eval + depth/generation kill-switch (HeldOutGuard)
|
| 9 |
+
|
| 10 |
+
## Context and Problem Statement
|
| 11 |
+
|
| 12 |
+
The framework drives a **self-evolving RL flywheel**: a generator proposes tasks,
|
| 13 |
+
the policy is optimized against an in-loop (proxy / oracle) reward, and the loop
|
| 14 |
+
repeats across generations. ADR-010 gave this loop its **per-task** safety
|
| 15 |
+
controls — the 4-gate solvability validator, the `HackMonitor` provenance check,
|
| 16 |
+
and the sandbox denylist (now hardened by `DockerSandbox`, see API §16). What was
|
| 17 |
+
still missing is the **run-level / across-generation** control: a watcher sitting
|
| 18 |
+
ABOVE the per-task gates that asks, every generation, *"is the proxy reward
|
| 19 |
+
improving because the policy got better, or because it learned to game the
|
| 20 |
+
proxy?"* — and HALTS the run when the answer is the latter.
|
| 21 |
+
|
| 22 |
+
The literature is unambiguous that a held-out eval + a hard stop is the
|
| 23 |
+
load-bearing control here, not a nice-to-have:
|
| 24 |
+
|
| 25 |
+
- **Reward hacking rises monotonically with optimization depth.** Zhao et al.,
|
| 26 |
+
*"Reward Hacking in Self-Improving Code Agents"* (ICLR 2026 Workshop on RSI,
|
| 27 |
+
OpenReview `ikrQWGgxYg`), show that going from 10 → 100 optimization steps
|
| 28 |
+
drives the hacking rate from 26.4% → 57.8% (+31.4 points), and that 73.8% of
|
| 29 |
+
KernelBench / 46.8% of ALE-Bench optimizations show **proxy gains without real
|
| 30 |
+
gains**. They define **Hacking Gap = proxy gain − real gain** and label an
|
| 31 |
+
optimization reward-hacking when it *"improves the public metric WITHOUT
|
| 32 |
+
improving the private metric"* — the canonical signature a run-level tripwire
|
| 33 |
+
must fire on. Because the hacking rate climbs with depth, a *one-time* eval is
|
| 34 |
+
insufficient; the control has to be an **online per-generation tripwire**.
|
| 35 |
+
|
| 36 |
+
- **Closed-loop RL on self-generated data collapses.** The self-evolving-agents
|
| 37 |
+
survey (Gao et al., TMLR 2026; arXiv 2507.21046 v4) **§8.3** names *"model
|
| 38 |
+
collapse from closed-loop RL on static synthetic data"* and prescribes
|
| 39 |
+
*"continuous monitoring … to detect long-horizon value drift."* Shumailov et
|
| 40 |
+
al. (*Nature* 2024, "AI models collapse when trained on recursively generated
|
| 41 |
+
data") show self-training first loses the distribution tails, then converges to
|
| 42 |
+
a low-variance point estimate. The mitigation that matters here: the held-out
|
| 43 |
+
eval must stay anchored to **REAL tasks that are NEVER fed back to the
|
| 44 |
+
generator**, otherwise the eval drifts with the train set and the gap signal
|
| 45 |
+
becomes meaningless.
|
| 46 |
+
|
| 47 |
+
- **Self-critique alone is insufficient.** The RSI paper's "retrospection"
|
| 48 |
+
self-critique sometimes *increased* hacking; its conclusion: *"mitigating
|
| 49 |
+
reward hacking likely requires stronger evaluations and constraints beyond
|
| 50 |
+
self-critique alone."* So the control is a genuinely disjoint held-out eval +
|
| 51 |
+
hard stop, not a critique hook.
|
| 52 |
+
|
| 53 |
+
- **A verifiable / held-out reward is still gameable.** EvilGenie
|
| 54 |
+
(arXiv 2511.21654) found *"only minimal improvement from the use of held out
|
| 55 |
+
test cases"* in isolation and that *"holdout tests have many surprising failure
|
| 56 |
+
modes."* The implication is NOT "skip the held-out eval" but "do not treat it
|
| 57 |
+
as sufficient on its own" — layer it explicitly as **defense-in-depth** on top
|
| 58 |
+
of `HackMonitor` (provenance) and the KL hard stop. Catastrophic Goodhart
|
| 59 |
+
(OpenReview `UXuBzWoZGK`) likewise proves KL regularization alone does NOT
|
| 60 |
+
prevent heavy-tailed reward misspecification — so the KL stop is one tripwire
|
| 61 |
+
among several, never the sole control.
|
| 62 |
+
|
| 63 |
+
## Decision
|
| 64 |
+
|
| 65 |
+
Build **`composer_replication.safety`** — a pure-Python, no-torch / no-cloud,
|
| 66 |
+
fully CPU-testable run-level safeguard — centered on the **`HeldOutGuard`**
|
| 67 |
+
kill-switch. The guard is **stateful** and is called once per checkpoint /
|
| 68 |
+
generation (`update(round_idx, in_loop_reward, heldout_score, kl_to_init=…)`),
|
| 69 |
+
the same cadence as `DifficultyCurriculum.update`. It maintains denoised EMAs of
|
| 70 |
+
every metric (raw single-step values are too noisy to threshold) and returns a
|
| 71 |
+
structured `TripwireStatus`.
|
| 72 |
+
|
| 73 |
+
### The 3 fire conditions
|
| 74 |
+
|
| 75 |
+
`HeldOutGuard.update` returns `fire=True` (alias `halt`) when **ANY** of:
|
| 76 |
+
|
| 77 |
+
- **(a) collapse-caught-in-the-act** — the in-loop reward EMA is RISING while the
|
| 78 |
+
held-out score EMA has DECLINED for `>= decline_patience` consecutive
|
| 79 |
+
checkpoints (default 3, the "monotone for ≥3 checkpoints" rule). This is the
|
| 80 |
+
canonical reward-hacking signature: proxy up, real down. A held-out dip during
|
| 81 |
+
an in-loop dip is treated as noise (a hard batch), not hacking — the decline
|
| 82 |
+
streak only grows when in-loop is *simultaneously* rising.
|
| 83 |
+
|
| 84 |
+
- **(b) KL-to-init hard stop** — the `kl_to_init` EMA exceeds `kl_hard_stop`
|
| 85 |
+
(default **0.08 nats/token**) on/after `min_steps`. Checked first as the
|
| 86 |
+
cheapest unambiguous breach.
|
| 87 |
+
|
| 88 |
+
- **(c) proxy-real gap blowout** — the Hacking Gap (proxy gain − real gain since a
|
| 89 |
+
run-start baseline) widens beyond `max_proxy_real_gap` (default 0.10), catching
|
| 90 |
+
a fast single-generation divergence even before the full decline window
|
| 91 |
+
elapses. `HeldOutGuard.proxy_real_gap()` returns exactly the RSI Hacking-Gap
|
| 92 |
+
quantity.
|
| 93 |
+
|
| 94 |
+
No tripwire fires before `min_steps` (default 20) to avoid halting on early-run
|
| 95 |
+
warm-up noise. Once fired, the verdict is **latched** — every subsequent `update`
|
| 96 |
+
keeps `fire=True`, so a transient post-collapse recovery cannot silently un-halt
|
| 97 |
+
the run.
|
| 98 |
+
|
| 99 |
+
### HeldoutSplit disjointness discipline (design-of-record)
|
| 100 |
+
|
| 101 |
+
The `heldout_score` fed to the guard MUST come from a **disjoint held-out eval
|
| 102 |
+
pool** — REAL tasks the generator NEVER trains on (the `HeldoutSplit`
|
| 103 |
+
discipline). This is the load-bearing precondition: per the self-evolving survey
|
| 104 |
+
§8.3 / Shumailov collapse dynamics, if the held-out set is allowed to drift with
|
| 105 |
+
the train set, the proxy-real gap signal degenerates and the guard becomes blind
|
| 106 |
+
to the exact collapse it exists to catch. The split is documented here as the
|
| 107 |
+
**design-of-record**; the guard consumes a scalar `heldout_score` and does not
|
| 108 |
+
itself partition data — the caller is responsible for keeping the split disjoint
|
| 109 |
+
and never feeding held-out tasks back into the generator.
|
| 110 |
+
|
| 111 |
+
### The 0.08 nats/token KL hard-stop default
|
| 112 |
+
|
| 113 |
+
The GRPO "healthy progression" band (Orchestra Research GRPO skill) climbs
|
| 114 |
+
0.02 → 0.05 → 0.08 → 0.12 nats/token over a run, with **0.08 the top of the "good
|
| 115 |
+
progression" band** and just below the code-generation drift zone (0.05–0.15
|
| 116 |
+
per-token; >0.5 is "diverging too much"). So 0.08 nats/token is a sound
|
| 117 |
+
hard-stop default. `calibrate_kl_threshold(baseline_kls, factor=3.0)` lets a run
|
| 118 |
+
adapt the ceiling to its own KL scale ("record baseline KL over the first ~100
|
| 119 |
+
steps, set max to 3× that") — but with a **safety clamp**: calibration may only
|
| 120 |
+
ever TIGHTEN the stop (`min(3×baseline, current)`), never loosen it past the
|
| 121 |
+
documented collapse band, so a noisy / already-drifting baseline cannot raise the
|
| 122 |
+
ceiling above 0.08.
|
| 123 |
+
|
| 124 |
+
> **UNITS GOTCHA (load-bearing).** `kl_to_init` is **token-mean KL in
|
| 125 |
+
> nats/token**, matching `composer_replication.integrations.altered_minds.
|
| 126 |
+
> kl_logging.token_mean_kl`. It is NOT comparable to a sequence-level /
|
| 127 |
+
> sequence-summed KL (whose healthy band is ~0.05–10). Passing a sequence-summed
|
| 128 |
+
> KL into the per-token hard stop will fire it instantly.
|
| 129 |
+
|
| 130 |
+
### Public surface
|
| 131 |
+
|
| 132 |
+
`composer_replication.safety` re-exports:
|
| 133 |
+
`HeldOutGuard`, `TripwireStatus`, `CollapseStopError`,
|
| 134 |
+
`kl_token_trust_filter`. The guard exposes both flag-checking
|
| 135 |
+
(`should_halt()` / `status.fire` / `status.halt`) and exception-based
|
| 136 |
+
(`raise_if_fired(status) -> CollapseStopError`) control flow so a trainer loop can
|
| 137 |
+
use whichever convention it prefers. `kl_token_trust_filter` is the per-token
|
| 138 |
+
torchrl-style "KL-Mask" sibling (caller passes `0.5·(log π/π_ref)²`; returns
|
| 139 |
+
True to mask the token) — same 0.08 band, kept torch-free.
|
| 140 |
+
|
| 141 |
+
## Consequences
|
| 142 |
+
|
| 143 |
+
- **Positive**: the flywheel gains a run-level, online collapse tripwire that
|
| 144 |
+
fires on the literature's exact reward-hacking signature (proxy-up / real-down),
|
| 145 |
+
is denoised against single-step noise, and latches so a detected collapse
|
| 146 |
+
cannot un-halt. It is layered defense-in-depth ON TOP OF the per-task ADR-010
|
| 147 |
+
controls — neither sufficient alone (per EvilGenie / Catastrophic Goodhart).
|
| 148 |
+
- **Positive**: pure-Python and CPU-testable — `kl_to_init` is a float the caller
|
| 149 |
+
computes upstream, so the guard pulls no torch / cloud dependency and is unit
|
| 150 |
+
testable without a model.
|
| 151 |
+
- **Positive**: the thresholds are calibratable and the KL stop only ever
|
| 152 |
+
tightens, so the safety property (ceiling ≤ documented band) is preserved
|
| 153 |
+
across calibration.
|
| 154 |
+
- **Negative / honest**: a held-out eval is necessary but NOT sufficient by
|
| 155 |
+
itself (EvilGenie); the guard's value depends entirely on the caller honoring
|
| 156 |
+
the `HeldoutSplit` disjointness discipline. The KL stop is one tripwire among
|
| 157 |
+
several, not a Goodhart-proof guarantee. `entropy` / `reward_std` are tracked
|
| 158 |
+
and exposed but are NOT yet hard gates (early-warning instruments only).
|
| 159 |
+
- **Neutral**: `HeldoutSplit` ships as a documented design-of-record discipline
|
| 160 |
+
rather than an enforced data-partitioning class in this wave; the guard
|
| 161 |
+
consumes the scalar held-out score the caller provides.
|
| 162 |
+
|
| 163 |
+
## Acceptance gate
|
| 164 |
+
|
| 165 |
+
- [x] `HeldOutGuard.update(...)` folds in-loop / held-out / KL (+ entropy /
|
| 166 |
+
reward_std) EMAs and returns a `TripwireStatus`; fires on (a) collapse-in-the-act,
|
| 167 |
+
(b) KL > 0.08 nats/token, (c) proxy-real gap blowout; no fire before `min_steps`;
|
| 168 |
+
latched after first fire.
|
| 169 |
+
- [x] `proxy_real_gap()` returns the RSI Hacking-Gap (in-loop gain − held-out gain
|
| 170 |
+
since baseline); `should_halt()` / `last_status` are idempotent query helpers;
|
| 171 |
+
`raise_if_fired()` converts a fired verdict into `CollapseStopError`.
|
| 172 |
+
- [x] `calibrate_kl_threshold()` only ever TIGHTENS the hard stop (safety clamp);
|
| 173 |
+
raises on empty input.
|
| 174 |
+
- [x] `kl_token_trust_filter()` per-token KL-Mask helper, torch-free.
|
| 175 |
+
- [x] Pure-Python, CPU-only; `composer_replication.safety.__init__` re-exports the
|
| 176 |
+
public surface and references this ADR.
|
| 177 |
+
- [x] Documented in `docs/API_REFERENCE.md` §17.
|
| 178 |
+
|
| 179 |
+
## More Information
|
| 180 |
+
|
| 181 |
+
- `composer_replication/safety/kill_switch.py` — the implementation + the
|
| 182 |
+
primary-source citations inline.
|
| 183 |
+
- ADR-010 (FeatureDeletion datagen) — the per-task controls this layers above.
|
| 184 |
+
- `docs/API_REFERENCE.md` §16 (`DockerSandbox`) / §17 (`composer_replication.safety`).
|
| 185 |
+
- Zhao et al. RSI (OpenReview `ikrQWGgxYg`); Gao et al. self-evolving survey
|
| 186 |
+
§8.3 (arXiv 2507.21046 v4); Shumailov et al. (*Nature* 2024); EvilGenie
|
| 187 |
+
(arXiv 2511.21654); Catastrophic Goodhart (OpenReview `UXuBzWoZGK`).
|
|
@@ -16,6 +16,7 @@
|
|
| 16 |
| [ADR-012](ADR-012-close-review-findings.md) | Close open cross-family-review findings (KL/hint-routing/provenance/curriculum) | accepted (amends 008/009/010) | 2026-05-29 |
|
| 17 |
| [ADR-013](ADR-013-lma-integration-channel-ladder.md) | LMA integration — isolated-channel ladder (supersedes tie-in Phase-3 hyperparams) | accepted | 2026-05-29 |
|
| 18 |
| [ADR-014](ADR-014-policy-optimization-objective-menu.md) | Policy-optimization objective MENU: base RL objective selectable (default Dr.GRPO) over TRL 1.5.0 GRPOConfig (builds-on ADR-006/007/008) | accepted | 2026-05-30 |
|
|
|
|
| 19 |
|
| 20 |
Sorted by number ascending. ADRs are immutable after `accepted`; supersede or amend rather than edit.
|
| 21 |
|
|
|
|
| 16 |
| [ADR-012](ADR-012-close-review-findings.md) | Close open cross-family-review findings (KL/hint-routing/provenance/curriculum) | accepted (amends 008/009/010) | 2026-05-29 |
|
| 17 |
| [ADR-013](ADR-013-lma-integration-channel-ladder.md) | LMA integration — isolated-channel ladder (supersedes tie-in Phase-3 hyperparams) | accepted | 2026-05-29 |
|
| 18 |
| [ADR-014](ADR-014-policy-optimization-objective-menu.md) | Policy-optimization objective MENU: base RL objective selectable (default Dr.GRPO) over TRL 1.5.0 GRPOConfig (builds-on ADR-006/007/008) | accepted | 2026-05-30 |
|
| 19 |
+
| [ADR-015](ADR-015-holdout-killswitch.md) | Held-out disjoint eval + depth/generation kill-switch (run-level collapse safeguard #2): `HeldOutGuard` + `HeldoutSplit` in `composer_replication.safety` | accepted | 2026-06-09 |
|
| 20 |
|
| 21 |
Sorted by number ascending. ADRs are immutable after `accepted`; supersede or amend rather than edit.
|
| 22 |
|