Skip to content

Commit 84bbde9

Browse files
committed
feat(sleep): baseline-relative adversarial probes with rollout aggregation
- blocking is baseline-relative: identical source/probe pairs scored under baseline and candidate docs; a row is brittle only when the candidate gap worsens beyond the margin in a strict majority of rollout indices - evidence retains all four aggregated scores plus per-rollout samples - dream_adversarial_rollouts (cap 8); blocking requires >= 2 - _strip_polite_frame restricted to politeness-marked requests with negative tests for ability/permission/desire forms - baseline documents are required arguments so no caller can silently compare against an unintended baseline
1 parent f1b2c65 commit 84bbde9

8 files changed

Lines changed: 466 additions & 63 deletions

File tree

‎docs/sleep/README.md‎

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,8 @@ hinted and unhinted evidence are mixed), so it multiplies backend calls and toke
309309
use; configured dream rollouts and synthetic variants multiply the per-group work
310310
too. Each group inherits the configured edit budget, gate mode/metric,
311311
`gate_no_regression`, `dream_rollouts`, `dream_factor`, `dream_adversarial`,
312-
`dream_adversarial_blocking`, `dream_adversarial_margin`, `recall_k`, and
312+
`dream_adversarial_blocking`, `dream_adversarial_margin`,
313+
`dream_adversarial_rollouts`, `recall_k`, and
313314
`evolve_skill`. Recalled archive tasks are restricted to that same skill hint;
314315
shared memory is read-only in fan-out runs. Setting `evolve_skill` to `false`
315316
therefore disables per-skill proposals as well as the managed skill proposal.
@@ -341,20 +342,32 @@ unless explicit adversarial blocking adds a second rejection condition.
341342
| `recall_k` | `0` | Associative recall — pull the K most-similar past tasks (from a persisted archive) into tonight's dream. |
342343
| `dream_factor` | `0` | Add N lightweight synthetic variants of each task. |
343344
| `dream_adversarial` | `0` | Score up to N harmless request-frame variants per real training task against each gate-eligible candidate. The factor is capped at 3 per task and 256 probes per candidate. |
344-
| `dream_adversarial_blocking` | `false` | When `true`, reject a candidate if any probe drops beyond the configured margin. When `false`, surface the same evidence without changing the gate decision. |
345-
| `dream_adversarial_margin` | `0.0` | Tolerated source-to-probe score drop in `[0, 1]` before a row is marked brittle. |
345+
| `dream_adversarial_blocking` | `false` | When `true`, reject a candidate whose brittleness is candidate-introduced under the baseline-relative rule below. When `false`, surface the same evidence without changing the gate decision. Requires `dream_adversarial_rollouts >= 2`. |
346+
| `dream_adversarial_margin` | `0.0` | Tolerated worsening of the candidate gap relative to the baseline gap, in `[0, 1]`, before a row is marked brittle. Calibrate it on your own task mix before enabling blocking. |
347+
| `dream_adversarial_rollouts` | `1` | Repeated samples per task and arm (capped at 8). Blocking requires at least 2 so one stochastic sample can never reject a candidate. |
346348

347349
Adversarial probes preserve the source reference and judge but change only the
348-
request frame (for example, removing polite boilerplate or adding explicit
349-
request delimiters). They are generated from real, underived training tasks
350-
only. Recalled, already-synthetic, validation, and test tasks are excluded.
351-
The report records every source/probe score and the exact perturbation that
352-
failed. Probes are advisory first because any fixed robustness suite is an
353-
incomplete proxy; enable blocking only after reviewing its behavior on your
354-
task mix. Blocking mode fails closed if no eligible probe can be generated.
355-
Each candidate adds one source rollout per eligible task plus N probe rollouts,
356-
so token and latency cost grow with the number of real training tasks and the
357-
selected factor.
350+
request frame (for example, removing explicitly politeness-marked boilerplate
351+
or adding request delimiters). They are generated from real, underived
352+
training tasks only. Recalled, already-synthetic, validation, and test tasks
353+
are excluded.
354+
355+
The decision is **baseline-relative** so pre-existing frame sensitivity never
356+
flags a candidate: every source/probe pair is scored under both the current
357+
(baseline) documents and the candidate documents, each score is the mean of
358+
`dream_adversarial_rollouts` repeated samples, and a row is brittle only when
359+
the candidate's probe-minus-source gap worsens beyond the margin relative to
360+
the baseline gap AND the worsening holds in a strict majority of rollout
361+
indices. All four aggregated scores and the per-rollout samples are retained
362+
in the evidence so the decision is auditable. Any non-finite score fails
363+
closed.
364+
365+
Probes are advisory first because any fixed robustness suite is an incomplete
366+
proxy; enable blocking only after reviewing the advisory evidence and
367+
calibrating the margin on your task mix. Blocking mode fails closed if no
368+
eligible probe can be generated. The replay cost per gate-eligible candidate
369+
is `rollouts * 2 * (sources + probes)`, so token and latency cost grow with
370+
the number of real training tasks, the factor, and the rollout count.
358371

359372
Example `~/.skillopt-sleep/config.json`:
360373

‎docs/sleep/multi-skill-staging.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ duplicated as a per-skill row.
4040

4141
Each group inherits `recall_k`, `dream_rollouts`, `dream_factor`,
4242
`dream_adversarial`, `dream_adversarial_blocking`,
43-
`dream_adversarial_margin`, `edit_budget`,
43+
`dream_adversarial_margin`, `dream_adversarial_rollouts`, `edit_budget`,
4444
`gate_mode`, `gate_metric`, `gate_mixed_weight`, `gate_no_regression`, and
4545
`evolve_skill`. Recalled archive tasks are restricted to the same skill hint,
4646
and shared memory is read-only during group runs. Consequently,

‎skillopt_sleep/adversarial.py‎

Lines changed: 165 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,55 @@
1818

1919
from skillopt_sleep.backend import Backend
2020
from skillopt_sleep.gate import select_gate_score
21-
from skillopt_sleep.replay import replay_batch
21+
from skillopt_sleep.replay import replay_one
2222
from skillopt_sleep.types import ReplayResult, TaskRecord
2323

2424
MAX_PROBES_PER_TASK = 3
2525
MAX_ADVERSARIAL_PROBES = 256
26+
MAX_PROBE_ROLLOUTS = 8
27+
# Blocking decisions need repeated rollouts so a single stochastic sample can
28+
# never reject a candidate on its own; advisory runs may use one rollout.
29+
MIN_BLOCKING_ROLLOUTS = 2
2630

2731

2832
def _normalize_split(value: str) -> str:
2933
return {"replay": "train", "holdout": "val"}.get(value, value)
3034

3135

3236
def _strip_polite_frame(intent: str) -> str:
33-
"""Remove only well-known request boilerplate; keep task semantics intact."""
34-
patterns = (
35-
r"(?is)^\s*please\s+",
36-
r"(?is)^\s*(?:can|could|would)\s+you\s+",
37-
r"(?is)^\s*i\s+(?:need|want)\s+you\s+to\s+",
38-
)
39-
for pattern in patterns:
37+
"""Reframe explicitly politeness-marked requests, and nothing else.
38+
39+
Semantic-preservation contract: a transformation is emitted only when the
40+
removed prefix is an unambiguous request marker, so removal cannot change
41+
what is being asked:
42+
43+
* a leading ``please`` (a pure politeness marker), and
44+
* a leading ``can/could/would you please`` (a modal question that the
45+
politeness marker disambiguates as a request; the trailing question
46+
mark, if any, becomes a period because the reframed text is the same
47+
request in imperative form).
48+
49+
Bare modal questions (``Can you swim?``, ``Would you like tea?``) are
50+
never reframed: without the politeness marker they may ask about ability,
51+
permission, or desire, and stripping the modal changes the meaning. The
52+
same applies to first-person desire framings (``I want you to ...``),
53+
which earlier revisions stripped and this contract deliberately drops.
54+
"""
55+
modal_request = r"(?is)^\s*(?:can|could|would)\s+you\s+please\s+"
56+
plain_please = r"(?is)^\s*please\s+"
57+
for pattern, reframed_request in ((modal_request, True), (plain_please, False)):
4058
rewritten, count = re.subn(pattern, "", intent, count=1)
41-
if count and rewritten.strip():
42-
rewritten = rewritten.strip()
43-
return rewritten[:1].upper() + rewritten[1:]
59+
if not count or not rewritten.strip():
60+
continue
61+
rewritten = rewritten.strip()
62+
if not rewritten[:1].isalpha():
63+
return ""
64+
if reframed_request and rewritten.endswith("?"):
65+
rewritten = rewritten[:-1].rstrip()
66+
if not rewritten or not rewritten[:1].isalpha():
67+
return ""
68+
rewritten += "."
69+
return rewritten[:1].upper() + rewritten[1:]
4470
return ""
4571

4672

@@ -135,27 +161,80 @@ def _score(result: ReplayResult, metric: str, mixed_weight: float) -> float | No
135161
return value if math.isfinite(value) else None
136162

137163

164+
def _rollout_scores(
165+
backend: Backend,
166+
task: TaskRecord,
167+
skill: str,
168+
memory: str,
169+
*,
170+
metric: str,
171+
mixed_weight: float,
172+
rollouts: int,
173+
) -> List[float | None]:
174+
"""Score one task ``rollouts`` times under one document pair.
175+
176+
Each rollout uses a distinct ``sample_id`` so caching backends produce
177+
genuinely repeated samples instead of collapsing to one response.
178+
"""
179+
scores: List[float | None] = []
180+
for sample_id in range(rollouts):
181+
result = replay_one(backend, task, skill, memory, sample_id=sample_id)
182+
scores.append(_score(result, metric, mixed_weight))
183+
return scores
184+
185+
186+
def _mean(values: Sequence[float]) -> float:
187+
return sum(values) / len(values)
188+
189+
138190
def evaluate_adversarial_probes(
139191
backend: Backend,
140192
tasks: Sequence[TaskRecord],
141193
skill: str,
142194
memory: str,
143195
*,
196+
baseline_skill: str,
197+
baseline_memory: str,
144198
factor: int = 1,
145199
metric: str = "mixed",
146200
mixed_weight: float = 0.5,
147201
margin: float = 0.0,
202+
rollouts: int = 1,
148203
) -> Dict[str, Any]:
149-
"""Score source/probe pairs and return a JSON-safe brittleness report.
204+
"""Score identical source/probe pairs under the BASELINE and the CANDIDATE
205+
documents and report candidate-introduced brittleness.
150206
151-
A row is flagged when its probe score falls more than ``margin`` below the
152-
matching source score. Non-finite scores are invalid and fail closed.
207+
Decision rule (documented so the evidence is auditable):
208+
209+
* every task in every arm is replayed ``rollouts`` times and the arm's
210+
score for that task is the MEAN of those rollouts;
211+
* per row, ``gap = probe_score - source_score`` is computed for both the
212+
baseline arm and the candidate arm, and
213+
``gap_change = candidate_gap - baseline_gap``;
214+
* a row is brittle only when ``gap_change < -margin`` AND the per-index
215+
paired worsening holds in a strict majority of rollout indices;
216+
* any non-finite score in any arm marks the row invalid, which fails
217+
closed: invalid rows count as flagged.
218+
219+
Frame sensitivity already present under the baseline documents therefore
220+
never flags a candidate; only the change the candidate introduces does.
221+
All four aggregated scores and the per-rollout samples are retained per
222+
row so the decision can be audited from the evidence alone. The total
223+
replay cost is ``rollouts * 2 * (n_sources + n_probes)``.
153224
"""
154225
if isinstance(margin, bool) or not isinstance(margin, (int, float)):
155226
raise ValueError("adversarial probe margin must be a finite number in [0, 1]")
156227
numeric_margin = float(margin)
157228
if not math.isfinite(numeric_margin) or not 0.0 <= numeric_margin <= 1.0:
158229
raise ValueError("adversarial probe margin must be a finite number in [0, 1]")
230+
if isinstance(rollouts, bool) or not isinstance(rollouts, int):
231+
raise ValueError(
232+
f"adversarial probe rollouts must be an integer in [1, {MAX_PROBE_ROLLOUTS}]"
233+
)
234+
if not 1 <= rollouts <= MAX_PROBE_ROLLOUTS:
235+
raise ValueError(
236+
f"adversarial probe rollouts must be an integer in [1, {MAX_PROBE_ROLLOUTS}]"
237+
)
159238

160239
probes = generate_adversarial_probes(tasks, factor=factor)
161240
source_by_id = {
@@ -167,32 +246,73 @@ def evaluate_adversarial_probes(
167246
and "recall" not in (task.tags or [])
168247
}
169248
source_ids = list(dict.fromkeys(probe.derived_from for probe in probes))
170-
source_tasks = [source_by_id[source_id] for source_id in source_ids]
171-
source_pairs = replay_batch(backend, source_tasks, skill, memory)
172-
probe_pairs = replay_batch(backend, probes, skill, memory)
173-
source_results = {task.id: result for task, result in source_pairs}
249+
arms = {
250+
"baseline": (baseline_skill, baseline_memory),
251+
"candidate": (skill, memory),
252+
}
253+
source_scores: Dict[str, Dict[str, List[float | None]]] = {}
254+
probe_scores: Dict[str, Dict[str, List[float | None]]] = {}
255+
for arm, (arm_skill, arm_memory) in arms.items():
256+
source_scores[arm] = {
257+
source_id: _rollout_scores(
258+
backend, source_by_id[source_id], arm_skill, arm_memory,
259+
metric=metric, mixed_weight=mixed_weight, rollouts=rollouts,
260+
)
261+
for source_id in source_ids
262+
}
263+
probe_scores[arm] = {
264+
probe.id: _rollout_scores(
265+
backend, probe, arm_skill, arm_memory,
266+
metric=metric, mixed_weight=mixed_weight, rollouts=rollouts,
267+
)
268+
for probe in probes
269+
}
174270

175271
rows: List[Dict[str, Any]] = []
176272
flagged = 0
177273
invalid = 0
178-
deltas: List[float] = []
179-
for probe, probe_result in probe_pairs:
180-
source_result = source_results.get(probe.derived_from)
181-
source_score = (
182-
_score(source_result, metric, mixed_weight)
183-
if source_result is not None
184-
else None
274+
gap_changes: List[float] = []
275+
for probe in probes:
276+
samples = {
277+
"baseline_source": source_scores["baseline"][probe.derived_from],
278+
"baseline_probe": probe_scores["baseline"][probe.id],
279+
"candidate_source": source_scores["candidate"][probe.derived_from],
280+
"candidate_probe": probe_scores["candidate"][probe.id],
281+
}
282+
valid = all(
283+
score is not None for scores in samples.values() for score in scores
185284
)
186-
probe_score = _score(probe_result, metric, mixed_weight)
187-
valid = source_score is not None and probe_score is not None
188-
delta = probe_score - source_score if valid else None
189-
is_flagged = not valid or bool(delta is not None and delta < -numeric_margin)
190-
if is_flagged:
285+
if valid:
286+
means = {name: _mean(scores) for name, scores in samples.items()}
287+
baseline_gap = means["baseline_probe"] - means["baseline_source"]
288+
candidate_gap = means["candidate_probe"] - means["candidate_source"]
289+
gap_change = candidate_gap - baseline_gap
290+
worsened = sum(
291+
1
292+
for index in range(rollouts)
293+
if (
294+
samples["candidate_probe"][index]
295+
- samples["candidate_source"][index]
296+
)
297+
- (
298+
samples["baseline_probe"][index]
299+
- samples["baseline_source"][index]
300+
)
301+
< 0.0
302+
)
303+
worsening_fraction = worsened / rollouts
304+
majority_worsened = worsened * 2 > rollouts
305+
is_brittle = gap_change < -numeric_margin and majority_worsened
306+
gap_changes.append(gap_change)
307+
else:
308+
means = {name: None for name in samples}
309+
baseline_gap = candidate_gap = gap_change = None
310+
worsening_fraction = None
311+
is_brittle = True
312+
if is_brittle:
191313
flagged += 1
192314
if not valid:
193315
invalid += 1
194-
if delta is not None:
195-
deltas.append(delta)
196316
kind = next(
197317
(tag.removeprefix("probe:") for tag in probe.tags if tag.startswith("probe:")),
198318
"unknown",
@@ -201,23 +321,30 @@ def evaluate_adversarial_probes(
201321
"source_task_id": probe.derived_from,
202322
"probe_task_id": probe.id,
203323
"probe_kind": kind,
204-
"source_score": source_score,
205-
"probe_score": probe_score,
206-
"delta": delta,
207-
"status": "invalid" if not valid else ("brittle" if is_flagged else "stable"),
324+
"baseline_source_score": means["baseline_source"],
325+
"baseline_probe_score": means["baseline_probe"],
326+
"candidate_source_score": means["candidate_source"],
327+
"candidate_probe_score": means["candidate_probe"],
328+
"baseline_gap": baseline_gap,
329+
"candidate_gap": candidate_gap,
330+
"gap_change": gap_change,
331+
"worsening_fraction": worsening_fraction,
332+
"samples": {name: list(scores) for name, scores in samples.items()},
333+
"status": "invalid" if not valid else ("brittle" if is_brittle else "stable"),
208334
})
209335

210336
n = len(rows)
211337
return {
212338
"enabled": True,
213339
"factor": max(0, min(factor, MAX_PROBES_PER_TASK)),
214340
"margin": numeric_margin,
215-
"n_sources": len(source_tasks),
341+
"rollouts": rollouts,
342+
"n_sources": len(source_ids),
216343
"n_probes": n,
217344
"n_flagged": flagged,
218345
"n_invalid": invalid,
219346
"brittleness_rate": (flagged / n) if n else 0.0,
220-
"worst_delta": min(deltas) if deltas else None,
347+
"worst_gap_change": min(gap_changes) if gap_changes else None,
221348
"conclusive": n > 0,
222349
"flagged": flagged > 0,
223350
"rows": rows,

‎skillopt_sleep/config.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"dream_factor": 0, # >0 => add N synthetic variants of each task to the dream
7373
"dream_adversarial": 0, # >0 => score N robustness probes per real train task
7474
"dream_adversarial_blocking": False, # reject flagged candidates instead of advisory-only
75-
"dream_adversarial_margin": 0.0, # tolerated source->probe score drop in [0, 1]
75+
"dream_adversarial_margin": 0.0, # tolerated worsening of the candidate-vs-baseline gap in [0, 1]
76+
"dream_adversarial_rollouts": 1, # samples per task/arm; blocking requires >= 2
7677
"recall_k": 0, # >0 => recall the K most-similar past tasks into the dream
7778
"evolve_memory": True, # consolidate CLAUDE.md
7879
"evolve_skill": True, # consolidate the managed SKILL.md

0 commit comments

Comments
 (0)