Baladithya Balamurugan Claude Opus 4.8 (1M context) commited on
Commit
bd0c358
·
1 Parent(s): 7a55e1e

Wave 3: close the HIGH review findings (kill-switch wiring, HeldoutSplit, EKS entrypoint bug)

Browse files

Phase-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 CHANGED
@@ -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 as of Wave 15: 115 passing + 1 skip-marked.** Wave-by-wave: 72 (W12) → 93 (W13) → 124 (W14) → 130 (W14b) → 115 (W15: TAID rewrite consolidated 16 schedule-tests into 7 t-paramaterized tests; OPSD parity test added skip-marked). See `docs/V1_V8_COVERAGE.md` for the canonical running count.
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
 
composer_replication/diloco/serverless/replica_entrypoint.py CHANGED
@@ -92,18 +92,50 @@ if __name__ == "__main__":
92
  import argparse
93
  import json
94
 
 
 
 
 
 
 
 
 
 
 
95
  parser = argparse.ArgumentParser()
96
- parser.add_argument("--rendezvous", required=True)
97
- parser.add_argument("--world-size", type=int, required=True)
98
- parser.add_argument("--trainer-module", required=True)
99
- parser.add_argument("--trainer-fn", default="train")
100
- parser.add_argument("--trainer-kwargs-json", default="{}")
101
  args = parser.parse_args()
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  main(
104
- rendezvous_uri=args.rendezvous,
105
- world_size=args.world_size,
106
- trainer_module=args.trainer_module,
107
- trainer_fn=args.trainer_fn,
108
- trainer_kwargs=json.loads(args.trainer_kwargs_json),
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
  )
composer_replication/safety/__init__.py CHANGED
@@ -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-*.md and the
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
  ]
composer_replication/safety/holdout.py ADDED
@@ -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()}
composer_replication/safety/kill_switch.py CHANGED
@@ -401,20 +401,37 @@ class HeldOutGuard:
401
 
402
  Args:
403
  baseline_kls: per-step token-mean KL values from early in the run.
404
- factor: multiplier on the baseline mean (default 3.0).
 
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
- self.kl_hard_stop = min(calibrated, self.kl_hard_stop)
 
 
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
  # ------------------------------------------------------------------------
composer_replication/safety/tests/test_holdout.py ADDED
@@ -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
composer_replication/safety/tests/test_kill_switch.py CHANGED
@@ -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]
composer_replication/trainer/composer_trainer.py CHANGED
@@ -26,10 +26,14 @@ The data collator (data_collator.py) is responsible for:
26
  from __future__ import annotations
27
 
28
  import logging
29
- from typing import Any
 
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
  # ----------------------------------------------------------------------
composer_replication/trainer/tests/test_killswitch_integration.py ADDED
@@ -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']}"
docs/API_REFERENCE.md CHANGED
@@ -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
 
docs/BACKLOG_RESOLUTION_2026-06-09.md CHANGED
@@ -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
docs/adrs/ADR-015-holdout-killswitch.md ADDED
@@ -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`).
docs/adrs/README.md CHANGED
@@ -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