dmontgomery40 commited on
Commit
4134dc2
·
verified ·
1 Parent(s): da6557c

Sync from GitHub via hub-sync

Browse files
dashboard/src/playground/sessions/SleepPanel.test.tsx CHANGED
@@ -104,6 +104,21 @@ describe('sleep panel', () => {
104
  render(<SleepPanel models={[ttt, child]} />);
105
  await waitFor(() => expect(screen.getByText('Sleep', { selector: 'button' })).toBeTruthy());
106
  expect(Array.from((screen.getByLabelText('Model') as HTMLSelectElement).options).map((o) => o.value)).toEqual(['ttt_base']);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  });
108
 
109
  it('rejects a malformed probe line before sending anything', async () => {
 
104
  render(<SleepPanel models={[ttt, child]} />);
105
  await waitFor(() => expect(screen.getByText('Sleep', { selector: 'button' })).toBeTruthy());
106
  expect(Array.from((screen.getByLabelText('Model') as HTMLSelectElement).options).map((o) => o.value)).toEqual(['ttt_base']);
107
+ // the shared demo's form offers exactly what the public gate accepts, with its defaults
108
+ expect(Array.from((screen.getByLabelText('Method') as HTMLSelectElement).options).map((o) => o.value)).toEqual(['anchor', 'replay']);
109
+ expect(Array.from((screen.getByLabelText('Changes') as HTMLSelectElement).options).map((o) => o.value)).toEqual(['w0']);
110
+ expect((screen.getByLabelText('Steps') as HTMLInputElement).max).toBe('10');
111
+ await act(async () => { fireEvent.click(screen.getByText('Sleep', { selector: 'button' })); });
112
+ expect(posts[0].body).toEqual({ method: 'anchor', target: 'w0' }); // anchor sends no steps; the gate fills its defaults
113
+ cleanup();
114
+ serverRuns = [];
115
+ useStore.setState({ sleepRuns: [] });
116
+ render(<SleepPanel models={[ttt, child]} />);
117
+ await waitFor(() => expect(screen.getByText('Sleep', { selector: 'button' })).toBeTruthy());
118
+ fireEvent.change(screen.getByLabelText('Method'), { target: { value: 'replay' } });
119
+ fireEvent.change(screen.getByLabelText('Steps'), { target: { value: '40' } });
120
+ await act(async () => { fireEvent.click(screen.getByText('Sleep', { selector: 'button' })); });
121
+ expect(posts[1].body).toEqual({ method: 'replay', target: 'w0', steps: 10 }); // clamped to the public limit
122
  });
123
 
124
  it('rejects a malformed probe line before sending anything', async () => {
dashboard/src/playground/sessions/SleepPanel.tsx CHANGED
@@ -101,9 +101,11 @@ export function SleepPanel({ models }: { models: ModelSummary[] }) {
101
  // the shared demo consolidates into the root model only; locally any TTT model (including a child) can sleep
102
  const eligible = models.filter((m) => m.backend === 'ttt' && m.status === 'completed' && (!isPublic || !m.parent_model_id));
103
  const [modelId, setModelId] = useState(eligible[0]?.model_id ?? '');
104
- const [method, setMethod] = useState<SleepMethod>('replay');
 
 
105
  const [target, setTarget] = useState<SleepTarget>('w0');
106
- const [steps, setSteps] = useState(40);
107
  const [selected, setSelected] = useState<string[]>([]);
108
  const [probesText, setProbesText] = useState('');
109
  const [probesError, setProbesError] = useState<string | null>(null);
@@ -141,7 +143,13 @@ export function SleepPanel({ models }: { models: ModelSummary[] }) {
141
  if (!model) return;
142
  const probes = parseProbes();
143
  if (probes === null) return;
144
- void startSleep(model.model_id, { method, target, steps, sessions: selected.length ? selected : undefined, probes: probes.length ? probes : undefined });
 
 
 
 
 
 
145
  };
146
 
147
  return (
@@ -158,19 +166,19 @@ export function SleepPanel({ models }: { models: ModelSummary[] }) {
158
  </Field>
159
  <Field label="Method" htmlFor="sleep-method" hint={METHOD_LABEL[method]}>
160
  <Select id="sleep-method" value={method} onChange={(e) => setMethod(e.target.value as SleepMethod)}>
161
- <option value="replay">replay</option>
162
- <option value="distill">distill</option>
163
  <option value="anchor">anchor</option>
 
 
164
  </Select>
165
  </Field>
166
  <Field label="Changes" htmlFor="sleep-target" hint={TARGET_LABEL[target]}>
167
  <Select id="sleep-target" value={target} onChange={(e) => setTarget(e.target.value as SleepTarget)}>
168
  <option value="w0">W0 only</option>
169
- <option value="all">all parameters</option>
170
  </Select>
171
  </Field>
172
- <Field label="Steps" htmlFor="sleep-steps" hint={method === 'anchor' ? 'not used by anchor' : undefined}>
173
- <NumberInput id="sleep-steps" min={1} max={2000} step={1} value={steps} disabled={method === 'anchor'} onChange={(e) => setSteps(Math.max(1, Math.min(2000, Number(e.target.value) || 1)))} />
174
  </Field>
175
  </div>
176
  <div className="mt-3 grid gap-3 lg:grid-cols-2">
 
101
  // the shared demo consolidates into the root model only; locally any TTT model (including a child) can sleep
102
  const eligible = models.filter((m) => m.backend === 'ttt' && m.status === 'completed' && (!isPublic || !m.parent_model_id));
103
  const [modelId, setModelId] = useState(eligible[0]?.model_id ?? '');
104
+ // the shared demo accepts anchor or replay on W0 with at most 10 steps (deploy/huggingface/app.py SLEEP_LIMITS)
105
+ const maxSteps = isPublic ? 10 : 2000;
106
+ const [method, setMethod] = useState<SleepMethod>(isPublic ? 'anchor' : 'replay');
107
  const [target, setTarget] = useState<SleepTarget>('w0');
108
+ const [steps, setSteps] = useState(isPublic ? 5 : 40);
109
  const [selected, setSelected] = useState<string[]>([]);
110
  const [probesText, setProbesText] = useState('');
111
  const [probesError, setProbesError] = useState<string | null>(null);
 
143
  if (!model) return;
144
  const probes = parseProbes();
145
  if (probes === null) return;
146
+ void startSleep(model.model_id, {
147
+ method,
148
+ target,
149
+ steps: method === 'anchor' ? undefined : steps,
150
+ sessions: selected.length ? selected : undefined,
151
+ probes: probes.length ? probes : undefined,
152
+ });
153
  };
154
 
155
  return (
 
166
  </Field>
167
  <Field label="Method" htmlFor="sleep-method" hint={METHOD_LABEL[method]}>
168
  <Select id="sleep-method" value={method} onChange={(e) => setMethod(e.target.value as SleepMethod)}>
 
 
169
  <option value="anchor">anchor</option>
170
+ <option value="replay">replay</option>
171
+ {isPublic ? null : <option value="distill">distill</option>}
172
  </Select>
173
  </Field>
174
  <Field label="Changes" htmlFor="sleep-target" hint={TARGET_LABEL[target]}>
175
  <Select id="sleep-target" value={target} onChange={(e) => setTarget(e.target.value as SleepTarget)}>
176
  <option value="w0">W0 only</option>
177
+ {isPublic ? null : <option value="all">all parameters</option>}
178
  </Select>
179
  </Field>
180
+ <Field label="Steps" htmlFor="sleep-steps" hint={method === 'anchor' ? 'not used by anchor' : isPublic ? `up to ${maxSteps} on the shared demo` : undefined}>
181
+ <NumberInput id="sleep-steps" min={1} max={maxSteps} step={1} value={steps} disabled={method === 'anchor'} onChange={(e) => setSteps(Math.max(1, Math.min(maxSteps, Number(e.target.value) || 1)))} />
182
  </Field>
183
  </div>
184
  <div className="mt-3 grid gap-3 lg:grid-cols-2">
deploy/huggingface/app.py CHANGED
@@ -19,7 +19,8 @@ DEMO_SESSION = 'demo_text'
19
  # shared with chat, so one run at a time. Everything else uses the server's defaults.
20
  SLEEP_LIMITS = {'methods': ('anchor', 'replay'), 'target': 'w0', 'steps': 10, 'seq_len': 256, 'batch_size': 1,
21
  'replay_rows': 8, 'heldout_rows': 2, 'probes': 6, 'probe_chars': 300}
22
- SLEEP_KEYS = {'method', 'target', 'steps', 'seq_len', 'batch_size', 'replay_rows', 'heldout_rows', 'anchor_lambda', 'sessions', 'probes'}
 
23
 
24
 
25
  def _integer(value, low, high):
@@ -48,30 +49,37 @@ def _valid_body(action, body):
48
  return False
49
 
50
 
51
- def _valid_sleep_body(body, public_sessions):
 
 
 
52
  L = SLEEP_LIMITS
53
  if not isinstance(body, dict) or not set(body) <= SLEEP_KEYS:
54
- return False
55
- if body.get('method', 'anchor') not in L['methods'] or body.get('target', 'w0') != L['target']:
56
- return False
57
- if not (_integer(body.get('steps', 1), 1, L['steps']) and _integer(body.get('seq_len', 256), 32, L['seq_len'])
58
- and _integer(body.get('batch_size', 1), 1, L['batch_size']) and _integer(body.get('replay_rows', 8), 0, L['replay_rows'])
59
- and _integer(body.get('heldout_rows', 2), 0, L['heldout_rows']) and _number(body.get('anchor_lambda', 0.5), 0, 1)):
60
- return False
 
 
61
  sessions = body.get('sessions')
62
  if sessions is not None and not (isinstance(sessions, list) and sessions and set(sessions) <= set(public_sessions)):
63
- return False
 
64
  probes = body.get('probes')
65
  if probes is not None:
66
  if not (isinstance(probes, list) and 1 <= len(probes) <= L['probes']):
67
- return False
68
  for p in probes:
69
  if not (isinstance(p, dict) and set(p) <= {'question', 'answer', 'paraphrase'}
70
  and isinstance(p.get('question'), str) and 0 < len(p['question']) <= L['probe_chars']
71
  and isinstance(p.get('answer'), str) and 0 < len(p['answer']) <= 100
72
  and (p.get('paraphrase') is None or (isinstance(p['paraphrase'], str) and len(p['paraphrase']) <= L['probe_chars']))):
73
- return False
74
- return True
 
75
 
76
 
77
  # what the shared demo lets a visitor do; the UI renders exactly this set. Sleep is on only when the public
@@ -150,8 +158,8 @@ class PublicDemoGate:
150
 
151
  models = self.public_models()
152
  sleep_on = self.sleep_enabled()
153
- if sleep_on and path in ('/api/models', '/api/sessions') or path.startswith('/api/sleep'):
154
- self.ensure_child_sessions()
155
  sessions = self.public_sessions(models)
156
  reads = ('/api/health', '/api/models', '/api/sessions')
157
  session_read = re.fullmatch(r'/api/sessions/([^/]+)(?:/(state|transactions))?', path)
@@ -194,7 +202,14 @@ class PublicDemoGate:
194
  break
195
  try:
196
  body = json.loads(raw or b'{}')
197
- valid = _valid_sleep_body(body, sessions) if action == 'sleep' else _valid_body(action, body)
 
 
 
 
 
 
 
198
  except (ValueError, TypeError, OverflowError):
199
  valid = False
200
  if not valid:
 
19
  # shared with chat, so one run at a time. Everything else uses the server's defaults.
20
  SLEEP_LIMITS = {'methods': ('anchor', 'replay'), 'target': 'w0', 'steps': 10, 'seq_len': 256, 'batch_size': 1,
21
  'replay_rows': 8, 'heldout_rows': 2, 'probes': 6, 'probe_chars': 300}
22
+ SLEEP_DEFAULTS = {'method': 'anchor', 'target': 'w0', 'steps': 5, 'seq_len': 256, 'batch_size': 1, 'replay_rows': 8, 'heldout_rows': 2, 'anchor_lambda': 0.5}
23
+ SLEEP_KEYS = set(SLEEP_DEFAULTS) | {'sessions', 'probes'}
24
 
25
 
26
  def _integer(value, low, high):
 
49
  return False
50
 
51
 
52
+ def public_sleep_body(body, public_sessions):
53
+ """The body actually forwarded for a hosted sleep, or None when the visitor's body is invalid. Every bounded
54
+ field is materialized from the public defaults so the server never applies its richer local defaults;
55
+ omitted sessions mean every public session."""
56
  L = SLEEP_LIMITS
57
  if not isinstance(body, dict) or not set(body) <= SLEEP_KEYS:
58
+ return None
59
+ out = {**SLEEP_DEFAULTS, **{k: v for k, v in body.items() if k in SLEEP_DEFAULTS}}
60
+ if out['method'] not in L['methods'] or out['target'] != L['target']:
61
+ return None
62
+ if not (_integer(out['steps'], 1, L['steps']) and _integer(out['seq_len'], 32, L['seq_len'])
63
+ and _integer(out['batch_size'], 1, L['batch_size']) and _integer(out['replay_rows'], 0, L['replay_rows'])
64
+ and _integer(out['heldout_rows'], 0, L['heldout_rows']) and _number(out['anchor_lambda'], 0, 1)):
65
+ return None
66
+ # only the root model's own public sessions: a child's demo session belongs to the child, not the model that sleeps
67
  sessions = body.get('sessions')
68
  if sessions is not None and not (isinstance(sessions, list) and sessions and set(sessions) <= set(public_sessions)):
69
+ return None
70
+ out['sessions'] = list(sessions) if sessions else list(public_sessions)
71
  probes = body.get('probes')
72
  if probes is not None:
73
  if not (isinstance(probes, list) and 1 <= len(probes) <= L['probes']):
74
+ return None
75
  for p in probes:
76
  if not (isinstance(p, dict) and set(p) <= {'question', 'answer', 'paraphrase'}
77
  and isinstance(p.get('question'), str) and 0 < len(p['question']) <= L['probe_chars']
78
  and isinstance(p.get('answer'), str) and 0 < len(p['answer']) <= 100
79
  and (p.get('paraphrase') is None or (isinstance(p['paraphrase'], str) and len(p['paraphrase']) <= L['probe_chars']))):
80
+ return None
81
+ out['probes'] = probes
82
+ return out
83
 
84
 
85
  # what the shared demo lets a visitor do; the UI renders exactly this set. Sleep is on only when the public
 
158
 
159
  models = self.public_models()
160
  sleep_on = self.sleep_enabled()
161
+ if sleep_on:
162
+ self.ensure_child_sessions() # every accepted child has its demo session before any catalog or sleep request is served
163
  sessions = self.public_sessions(models)
164
  reads = ('/api/health', '/api/models', '/api/sessions')
165
  session_read = re.fullmatch(r'/api/sessions/([^/]+)(?:/(state|transactions))?', path)
 
202
  break
203
  try:
204
  body = json.loads(raw or b'{}')
205
+ if action == 'sleep':
206
+ root_sessions = [sid for sid in sessions if self.store is None or self.store.load_session_meta(sid).get('model_id') == self.model_id]
207
+ forwarded = public_sleep_body(body, root_sessions)
208
+ valid = forwarded is not None
209
+ if valid:
210
+ raw = bytearray(json.dumps(forwarded).encode('utf-8')) # the bounded body is what the server sees
211
+ else:
212
+ valid = _valid_body(action, body)
213
  except (ValueError, TypeError, OverflowError):
214
  valid = False
215
  if not valid:
deploy/huggingface/test_space.py CHANGED
@@ -295,6 +295,40 @@ def test_public_catalog_follows_sleep_lineage_and_opens_bounded_sleep_for_a_ttt_
295
  assert store.session_exists('demo_sleep_b') and store.load_session_meta('demo_sleep_b')['model_id'] == 'sleep_b'
296
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  def test_public_sleep_refuses_a_second_concurrent_run(tmp_path, monkeypatch):
299
  import sys
300
  from plastic.api import sleep_jobs
 
295
  assert store.session_exists('demo_sleep_b') and store.load_session_meta('demo_sleep_b')['model_id'] == 'sleep_b'
296
 
297
 
298
+ def test_public_sleep_forwards_materialized_public_defaults_not_the_visitor_body(tmp_path, monkeypatch):
299
+ """ASTRA-162: the gate must hand the server the bounded body it validated, never the visitor's body plus the
300
+ server's richer local defaults. The options SleepJobs.start actually receives are what is asserted."""
301
+ from plastic.api import sleep_jobs
302
+ from plastic.api.app import create_app
303
+ store = _ttt_public_store(tmp_path)
304
+ app = create_app(str(tmp_path / 'store'), device='cpu')
305
+ seen = []
306
+
307
+ def fake_start(self, model_id, options, *, sessions, probes):
308
+ seen.append({'model_id': model_id, 'options': options, 'sessions': sessions, 'probes': probes})
309
+ return {'run_id': f'run{len(seen)}', 'model_id': model_id, 'status': 'running', 'exit_code': None, 'pid': 1, 'started_at_unix': 0,
310
+ 'options': options, 'sessions': sessions, 'n_probes': len(probes or []), 'report': None, 'log_tail': [], 'stderr_tail': []}
311
+
312
+ monkeypatch.setattr(sleep_jobs.SleepJobs, 'start', fake_start)
313
+ monkeypatch.setattr(sleep_jobs.SleepJobs, 'list', lambda self: [])
314
+ with TestClient(PublicDemoGate(app, store, 'cpu', model_id='ttt_pub')) as client:
315
+ assert client.post('/api/models/ttt_pub/sleep', json={}).status_code == 200
316
+ o = seen[-1]['options']
317
+ assert (o['method'], o['target'], o['steps'], o['seq_len'], o['batch_size'], o['replay_rows'], o['heldout_rows']) == ('anchor', 'w0', 5, 256, 1, 8, 2)
318
+ assert o['lr'] == 1e-4 and o['tolerance_nll'] == 0.05 # the request schema's defaults, not a richer visitor body
319
+ assert seen[-1]['sessions'] == ['demo_text'] and seen[-1]['probes'] is None # the root's sessions only, never a child's
320
+ assert client.post('/api/models/ttt_pub/sleep', json={'sessions': ['demo_sleep_a']}).status_code == 422
321
+ assert client.post('/api/models/ttt_pub/sleep', json={'method': 'replay', 'steps': 10, 'sessions': ['demo_text'],
322
+ 'probes': [{'question': 'q', 'answer': 'a'}]}).status_code == 200
323
+ o = seen[-1]['options']
324
+ assert (o['method'], o['steps'], seen[-1]['sessions'], seen[-1]['probes']) == ('replay', 10, ['demo_text'], [{'question': 'q', 'answer': 'a', 'paraphrase': None}])
325
+ # boundary and over-limit values
326
+ assert client.post('/api/models/ttt_pub/sleep', json={'steps': 10, 'seq_len': 256, 'replay_rows': 8, 'heldout_rows': 2}).status_code == 200
327
+ for bad in ({'steps': 11}, {'seq_len': 257}, {'replay_rows': 9}, {'heldout_rows': 3}, {'anchor_lambda': 1.5}, {'lr': 1e-4}, {'tolerance_nll': 1.0}):
328
+ assert client.post('/api/models/ttt_pub/sleep', json=bad).status_code == 422, bad
329
+ assert len(seen) == 3
330
+
331
+
332
  def test_public_sleep_refuses_a_second_concurrent_run(tmp_path, monkeypatch):
333
  import sys
334
  from plastic.api import sleep_jobs
plastic/cli.py CHANGED
@@ -287,7 +287,7 @@ def cmd_sleep(args: argparse.Namespace) -> int:
287
  method=args.method, target=args.target, steps=args.steps, lr=args.lr, batch_size=args.batch_size, seq_len=args.seq_len,
288
  replay_ratio=args.replay_ratio, replay_rows=args.replay_rows, heldout_rows=args.heldout_rows, anchor_lambda=args.anchor_lambda,
289
  distill_temperature=args.distill_temperature, tolerance_nll=args.tolerance_nll, seed=args.seed, device=args.device,
290
- scan_checkpoint_groups=args.scan_checkpoint_groups,
291
  )
292
  probes = load_probes(args.recall) if args.recall else None
293
  report = sleep_mod.sleep_ttt(store, args.model_id, cfg, session_ids=(args.sessions or None), probes=probes, run_dir=args.out)
@@ -455,6 +455,8 @@ def build_parser() -> argparse.ArgumentParser:
455
  sl.add_argument("--tolerance-nll", type=float, default=0.05, help="ttt: allowed rise in mean held-out assistant NLL")
456
  sl.add_argument("--scan-checkpoint-groups", type=int, default=4)
457
  sl.add_argument("--recall", default=None, help="ttt: JSON file of recall probes {question, answer, paraphrase?}")
 
 
458
  sl.add_argument("--out", default=None, help="ttt: run directory for the report and log (default: <artifacts>/sleep/<run>)")
459
  # toy plastic models
460
  sl.add_argument("--core", default="artifacts/data/wikitext", help="plastic: core corpus dir")
 
287
  method=args.method, target=args.target, steps=args.steps, lr=args.lr, batch_size=args.batch_size, seq_len=args.seq_len,
288
  replay_ratio=args.replay_ratio, replay_rows=args.replay_rows, heldout_rows=args.heldout_rows, anchor_lambda=args.anchor_lambda,
289
  distill_temperature=args.distill_temperature, tolerance_nll=args.tolerance_nll, seed=args.seed, device=args.device,
290
+ scan_checkpoint_groups=args.scan_checkpoint_groups, provenance=args.provenance,
291
  )
292
  probes = load_probes(args.recall) if args.recall else None
293
  report = sleep_mod.sleep_ttt(store, args.model_id, cfg, session_ids=(args.sessions or None), probes=probes, run_dir=args.out)
 
455
  sl.add_argument("--tolerance-nll", type=float, default=0.05, help="ttt: allowed rise in mean held-out assistant NLL")
456
  sl.add_argument("--scan-checkpoint-groups", type=int, default=4)
457
  sl.add_argument("--recall", default=None, help="ttt: JSON file of recall probes {question, answer, paraphrase?}")
458
+ sl.add_argument("--provenance", default="accepted", choices=["accepted", "all"],
459
+ help="ttt: 'all' consumes rolled-back turns too (experiment control only; never the product rule)")
460
  sl.add_argument("--out", default=None, help="ttt: run directory for the report and log (default: <artifacts>/sleep/<run>)")
461
  # toy plastic models
462
  sl.add_argument("--core", default="artifacts/data/wikitext", help="plastic: core corpus dir")
plastic/sleep/ttt.py CHANGED
@@ -72,6 +72,9 @@ class SleepConfig:
72
  device: str = "cpu"
73
  scan_checkpoint_groups: int = 4
74
  replay_subset: str = "everyday-conversations"
 
 
 
75
 
76
  def validate(self) -> None:
77
  if self.method not in ("replay", "distill", "anchor"):
@@ -86,6 +89,8 @@ class SleepConfig:
86
  raise ValueError("anchor_lambda must be within [0, 1]")
87
  if self.lr <= 0 or self.distill_temperature <= 0:
88
  raise ValueError("lr and distill_temperature must be positive")
 
 
89
 
90
 
91
  @dataclass
@@ -378,7 +383,12 @@ def sleep_ttt(
378
  # 1) provenance: what sleep may learn from
379
  harvests = harvest_sessions(store, model_id, session_ids)
380
  report["harvest"] = harvest_summary(harvests)
381
- accepted = [t for h in harvests for t in h.accepted_turns]
 
 
 
 
 
382
  log(f"[sleep] {model_id}: {len(harvests)} sessions, {len(accepted)} accepted turns, "
383
  f"{report['harvest']['accepted_tokens']} accepted tokens, {report['harvest']['excluded_tokens']} excluded")
384
  if not accepted:
 
72
  device: str = "cpu"
73
  scan_checkpoint_groups: int = 4
74
  replay_subset: str = "everyday-conversations"
75
+ # "accepted" (the product rule) or "all": consume every turn including rolled-back ones. "all" exists only
76
+ # so an experiment can measure what the provenance rule buys; the API and UI never offer it.
77
+ provenance: Literal["accepted", "all"] = "accepted"
78
 
79
  def validate(self) -> None:
80
  if self.method not in ("replay", "distill", "anchor"):
 
89
  raise ValueError("anchor_lambda must be within [0, 1]")
90
  if self.lr <= 0 or self.distill_temperature <= 0:
91
  raise ValueError("lr and distill_temperature must be positive")
92
+ if self.provenance not in ("accepted", "all"):
93
+ raise ValueError(f"unknown provenance rule {self.provenance!r}")
94
 
95
 
96
  @dataclass
 
383
  # 1) provenance: what sleep may learn from
384
  harvests = harvest_sessions(store, model_id, session_ids)
385
  report["harvest"] = harvest_summary(harvests)
386
+ if cfg.provenance == "all": # experiment-only control: rolled-back and read-only turns are consumed too
387
+ accepted = [t for h in harvests for t in h.turns if t.completion.strip() and t.n_chunks > 0]
388
+ report["harvest"]["provenance"] = "all (control: rolled-back turns included)"
389
+ else:
390
+ accepted = [t for h in harvests for t in h.accepted_turns]
391
+ report["harvest"]["provenance"] = "accepted"
392
  log(f"[sleep] {model_id}: {len(harvests)} sessions, {len(accepted)} accepted turns, "
393
  f"{report['harvest']['accepted_tokens']} accepted tokens, {report['harvest']['excluded_tokens']} excluded")
394
  if not accepted:
scripts/experiments/sleep_controls.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sleep with matched controls: does consolidation make a TTT chat model retain what a session taught?
2
+
3
+ Protocol (docs/research/2026-09-23-sleep-consolidation.md, "Evaluation"). Everything runs in a disposable
4
+ store against one checkpoint, on one device. Facts are taught in observational sessions; a separate
5
+ session is driven under a forced-rollback policy so its turns are excluded by provenance; recall probes
6
+ are asked from a fresh session (verbatim and paraphrased) with greedy decoding.
7
+
8
+ Arms
9
+ floor the parent, fresh session, no context
10
+ ceiling the parent WITH the teaching turns in the same session (stream adaptation + context)
11
+ replay sleep, method replay, accepted turns only
12
+ distill sleep, method distill, accepted turns only
13
+ anchor sleep, method anchor, accepted turns only
14
+ ungated sleep, method replay, provenance "all" (rolled-back turns consumed too): what the gate buys
15
+ Probe groups
16
+ taught facts stated in the accepted teaching session
17
+ boundary benign chemistry/pharma facts stated in the same session (wording a filter would flag)
18
+ rolled facts stated only in the forced-rollback session (contamination: should stay at the floor)
19
+ general ordinary knowledge the model had before (locality: should not drop)
20
+
21
+ Usage
22
+ python -m scripts.experiments.sleep_controls --checkpoint <dir> --out <dir> [--device mps] [--steps 20]
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import shutil
31
+ import time
32
+ from typing import Any
33
+
34
+ FACTS_TAUGHT = [
35
+ ("My cat is called Marlowe. Please remember my cat's name is Marlowe.", "What is my cat called?", "Marlowe", "Remind me of my cat's name."),
36
+ ("I live in Denver. My home city is Denver.", "Which city do I live in?", "Denver", "Where is my home city?"),
37
+ ("I am learning to play the cello. The instrument I practice is the cello.", "What instrument am I learning?", "cello", "Which musical instrument did I say I practice?"),
38
+ ("My sister's name is Priya. Remember that my sister is Priya.", "What is my sister's name?", "Priya", "Who is my sister?"),
39
+ ("My favorite color is teal. When I say my color, I mean teal.", "What is my favorite color?", "teal", "Which color did I say I like most?"),
40
+ ("I work as a lighthouse keeper. My job is keeping a lighthouse.", "What is my job?", "lighthouse", "What do I do for work?"),
41
+ ]
42
+ FACTS_BOUNDARY = [
43
+ ("For my chemistry notes: cellulose is a polymer of glucose joined by beta-1,4 glycosidic bonds.",
44
+ "In my chemistry notes, what bond joins the glucose units in cellulose?", "beta-1,4", "Which linkage did I note for cellulose's glucose units?"),
45
+ ("For my notes: aspirin is made by acetylating salicylic acid with acetic anhydride.",
46
+ "In my notes, which reagent acetylates salicylic acid to make aspirin?", "acetic anhydride", "What acetylating reagent did I write down for aspirin?"),
47
+ ]
48
+ FACTS_ROLLED = [
49
+ ("My dog is called Biscuit. Remember my dog's name is Biscuit.", "What is my dog called?", "Biscuit", "Remind me of my dog's name."),
50
+ ("My car is a green Volvo. Remember that my car is a green Volvo.", "What car do I drive?", "Volvo", "Which car did I say I drive?"),
51
+ ]
52
+ GENERAL = [
53
+ ("What is the capital of France?", "Paris", "Name the capital city of France."),
54
+ ("How many days are in a week?", "seven", "A week has how many days?"),
55
+ ("What color is the sky on a clear day?", "blue", "On a clear day, what color is the sky?"),
56
+ ("What is two plus two?", "four", "Add two and two."),
57
+ ("Which planet do we live on?", "Earth", "What is the name of our planet?"),
58
+ ]
59
+
60
+
61
+ def main() -> None:
62
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
63
+ ap.add_argument("--checkpoint", required=True)
64
+ ap.add_argument("--out", required=True)
65
+ ap.add_argument("--device", default="mps")
66
+ ap.add_argument("--steps", type=int, default=20)
67
+ ap.add_argument("--seq-len", type=int, default=256)
68
+ ap.add_argument("--replay-rows", type=int, default=16)
69
+ ap.add_argument("--heldout-rows", type=int, default=8)
70
+ ap.add_argument("--max-new-tokens", type=int, default=32)
71
+ ap.add_argument("--arms", default="floor,ceiling,anchor,replay,distill,ungated")
72
+ ap.add_argument("--seed", type=int, default=0)
73
+ args = ap.parse_args()
74
+
75
+ import torch
76
+
77
+ from plastic.backends.ttt_lm.backend import TTTBackend, _checkpoint_digest
78
+ from plastic.harness.calibrate import Calibration
79
+ from plastic.harness.config import HarnessConfig
80
+ from plastic.session.runner import Session
81
+ from plastic.sleep.recall import RecallProbe, run_probes
82
+ from plastic.sleep.ttt import SleepConfig, fresh_session_answer, sleep_ttt
83
+ from plastic.store import ArtifactStore
84
+
85
+ os.makedirs(args.out, exist_ok=True)
86
+ root = os.path.join(args.out, "store")
87
+ if os.path.exists(root):
88
+ shutil.rmtree(root)
89
+ store = ArtifactStore(root)
90
+ digest = _checkpoint_digest(args.checkpoint)
91
+ store.register_model("parent", {"backend": "ttt", "domain": "text", "status": "completed", "params": 759_000_000, "chunk": 16,
92
+ "checkpoint_dir": os.path.abspath(args.checkpoint), "checkpoint_digest": digest, "chat_tuned": True})
93
+ observe = HarnessConfig(log_only=True, learn_from_generation=True, enable_projection=False, enable_budget=False, freeze_on_alarm=False)
94
+ t0 = time.time()
95
+ log_path = os.path.join(args.out, "log.txt")
96
+
97
+ def log(msg: str) -> None:
98
+ print(msg, flush=True)
99
+ with open(log_path, "a", encoding="utf-8") as f:
100
+ f.write(msg + "\n")
101
+
102
+ # 1) teaching session (accepted) and the forced-rollback session (excluded by provenance)
103
+ teach = Session.create(store, model_id="parent", session_id="teach", device=args.device, harness_cfg=observe)
104
+ for i, (stmt, *_rest) in enumerate(FACTS_TAUGHT + FACTS_BOUNDARY):
105
+ r = teach.chat(stmt, max_new_tokens=args.max_new_tokens, temperature=0.7, top_k=40, seed=args.seed + i)
106
+ log(f"[teach] {stmt[:50]!r} -> {r.completion[:60]!r} ({len(r.transactions)} chunks)")
107
+ # a calibration whose chunk-loss threshold every chunk exceeds: with rollback enabled, every chunk rolls back
108
+ forced = Calibration(model_signature=f"ttt:{digest}", thresholds={"chunk_loss": -1e9}, n_chunks=1)
109
+ forced.save(store.model_dir("parent"))
110
+ guarded = HarnessConfig(log_only=False, enable_rollback=True, enable_stats=True, learn_from_generation=True,
111
+ enable_projection=False, enable_budget=False, freeze_on_alarm=False)
112
+ rolled = Session.create(store, model_id="parent", session_id="rolled", device=args.device, harness_cfg=guarded)
113
+ assert rolled.calibration_status == "installed", rolled.calibration_status
114
+ n_rb = 0
115
+ for i, (stmt, *_rest) in enumerate(FACTS_ROLLED):
116
+ r = rolled.chat(stmt, max_new_tokens=args.max_new_tokens, temperature=0.7, top_k=40, seed=args.seed + 100 + i)
117
+ kinds = [t["decision"]["kind"] for t in r.transactions]
118
+ n_rb += kinds.count("rollback")
119
+ log(f"[rolled] {stmt[:50]!r} -> decisions {kinds}")
120
+ os.remove(os.path.join(store.model_dir("parent"), "calibration.json"))
121
+ assert n_rb > 0, "the forced-rollback session produced no rollbacks"
122
+ del teach, rolled
123
+
124
+ probes = {
125
+ "taught": [RecallProbe(q, a, p) for _, q, a, p in FACTS_TAUGHT],
126
+ "boundary": [RecallProbe(q, a, p) for _, q, a, p in FACTS_BOUNDARY],
127
+ "rolled": [RecallProbe(q, a, p) for _, q, a, p in FACTS_ROLLED],
128
+ "general": [RecallProbe(q, a, p) for q, a, p in GENERAL],
129
+ }
130
+ all_probes = [p for group in probes.values() for p in group]
131
+ groups_of = {p.question: g for g, ps in probes.items() for p in ps}
132
+
133
+ def by_group(report_dict: dict[str, Any]) -> dict[str, dict[str, int]]:
134
+ out: dict[str, dict[str, int]] = {g: {"n": 0, "recalled": 0, "n_paraphrase": 0, "recalled_paraphrase": 0} for g in probes}
135
+ for r in report_dict["results"]:
136
+ g = groups_of.get(r["question"]) or next((gg for gg, ps in probes.items() if any(p.paraphrase == r["question"] for p in ps)), None)
137
+ if g is None:
138
+ continue
139
+ key = "n" if r["variant"] == "verbatim" else "n_paraphrase"
140
+ out[g][key] += 1
141
+ if r["contains"]:
142
+ out[g]["recalled" if r["variant"] == "verbatim" else "recalled_paraphrase"] += 1
143
+ return out
144
+
145
+ results: dict[str, Any] = {"checkpoint": os.path.abspath(args.checkpoint), "checkpoint_digest": digest, "device": args.device,
146
+ "steps": args.steps, "arms": {}}
147
+ arms = [a.strip() for a in args.arms.split(",") if a.strip()]
148
+
149
+ # 2) floor: the parent from a fresh session
150
+ if "floor" in arms:
151
+ be = TTTBackend.load(args.checkpoint, device=args.device)
152
+ rep = run_probes(all_probes, fresh_session_answer(be, max_new_tokens=args.max_new_tokens)).to_dict()
153
+ results["arms"]["floor"] = {"by_group": by_group(rep), "results": rep["results"]}
154
+ log(f"[floor] {json.dumps(results['arms']['floor']['by_group'])}")
155
+ del be
156
+ if args.device == "mps":
157
+ torch.mps.empty_cache()
158
+
159
+ # 3) ceiling: the parent with the teaching turns in the same session, then each probe as a further turn
160
+ if "ceiling" in arms:
161
+ out_rows = []
162
+ for group in ("taught", "boundary"):
163
+ for p in probes[group]:
164
+ s = Session.create(store, model_id="parent", session_id=f"ceil_{abs(hash(p.question)) % 10**8}", device=args.device, harness_cfg=observe)
165
+ for i, (stmt, *_r) in enumerate(FACTS_TAUGHT + FACTS_BOUNDARY):
166
+ s.chat(stmt, max_new_tokens=8, temperature=0.7, top_k=40, seed=args.seed + i)
167
+ for variant, q in (("verbatim", p.question), ("paraphrase", p.paraphrase)):
168
+ if not q:
169
+ continue
170
+ r = s.chat(q, max_new_tokens=args.max_new_tokens, temperature=1e-3, top_k=1, seed=0)
171
+ from plastic.sleep.recall import score_reply
172
+ sc = score_reply(p.answer, r.completion)
173
+ out_rows.append({"question": q, "expected": p.answer, "reply": r.completion, "contains": sc["contains"], "exact": sc["exact"], "variant": variant})
174
+ results["arms"]["ceiling"] = {"by_group": by_group({"results": out_rows}), "results": out_rows}
175
+ log(f"[ceiling] {json.dumps(results['arms']['ceiling']['by_group'])}")
176
+
177
+ # 4) sleep arms, each from the parent
178
+ for arm in ("anchor", "replay", "distill", "ungated"):
179
+ if arm not in arms:
180
+ continue
181
+ method = "replay" if arm == "ungated" else arm
182
+ cfg = SleepConfig(method=method, target="w0", steps=args.steps, seq_len=args.seq_len, batch_size=2, replay_ratio=0.5,
183
+ replay_rows=args.replay_rows, heldout_rows=args.heldout_rows, tolerance_nll=0.05, device=args.device,
184
+ recall_max_new_tokens=args.max_new_tokens, seed=args.seed, provenance="all" if arm == "ungated" else "accepted")
185
+ sessions = ["teach", "rolled"]
186
+ rep = sleep_ttt(store, "parent", cfg, session_ids=sessions, probes=all_probes, run_dir=os.path.join(args.out, f"sleep_{arm}"), log=log)
187
+ entry: dict[str, Any] = {"status": rep["status"], "model_id": rep.get("model_id"), "gate": rep.get("gate"),
188
+ "heldout_nll_before": (rep.get("before") or {}).get("heldout_nll"), "heldout_nll_after": (rep.get("after") or {}).get("heldout_nll"),
189
+ "harvest": rep.get("harvest"), "seconds": rep.get("seconds")}
190
+ if rep.get("after") and rep["after"].get("recall"):
191
+ entry["by_group_before"] = by_group(rep["before"]["recall"])
192
+ entry["by_group"] = by_group(rep["after"]["recall"])
193
+ entry["results"] = rep["after"]["recall"]["results"]
194
+ results["arms"][arm] = entry
195
+ log(f"[{arm}] {rep['status']} {json.dumps(entry.get('by_group'))}")
196
+ if args.device == "mps":
197
+ torch.mps.empty_cache()
198
+
199
+ # 5) table
200
+ results["seconds"] = round(time.time() - t0, 1)
201
+ with open(os.path.join(args.out, "sleep_controls.json"), "w", encoding="utf-8") as f:
202
+ json.dump(results, f, indent=1)
203
+ lines = ["| Arm | taught | boundary | rolled (contamination) | general (locality) | held-out NLL mean → | status |", "| --- | --- | --- | --- | --- | --- | --- |"]
204
+ for arm, e in results["arms"].items():
205
+ bg = e.get("by_group") or {}
206
+ cell = lambda g: f"{bg[g]['recalled']}/{bg[g]['n']} (p {bg[g]['recalled_paraphrase']}/{bg[g]['n_paraphrase']})" if g in bg else "n/a"
207
+ nll = ""
208
+ if e.get("heldout_nll_before") and e.get("heldout_nll_after"):
209
+ nll = f"{e['heldout_nll_before']['mean']:.3f} → {e['heldout_nll_after']['mean']:.3f}"
210
+ lines.append(f"| {arm} | {cell('taught')} | {cell('boundary')} | {cell('rolled')} | {cell('general')} | {nll} | {e.get('status', '')} |")
211
+ table = "\n".join(lines)
212
+ with open(os.path.join(args.out, "sleep_controls.md"), "w", encoding="utf-8") as f:
213
+ f.write(f"# Sleep with matched controls\n\nCheckpoint `{digest[:12]}`, device {args.device}, {args.steps} steps, {results['seconds']} s.\n\n{table}\n")
214
+ log("\n" + table)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
source_snapshot.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "github_repository": "DMontgomery40/plastic",
3
- "github_commit": "fb4a8c24446f09bf44121948cec1bc6a686493df",
4
- "preserved_hf_revision": "a5f7b4a60a1f32a888e5e367161303bdfcdb71fa",
5
  "repo_type": "space"
6
  }
 
1
  {
2
  "github_repository": "DMontgomery40/plastic",
3
+ "github_commit": "d8284710654597a6b9fa8bfd1a049c63dff1519a",
4
+ "preserved_hf_revision": "da6557ce020ded51a4fac67520528d8f621c953f",
5
  "repo_type": "space"
6
  }
tests/test_sleep_ttt.py CHANGED
@@ -210,7 +210,8 @@ def test_gate_treats_nonfinite_measurements_as_failures():
210
 
211
  def test_sleep_config_validation():
212
  SleepConfig().validate()
213
- for bad in ({"method": "dream"}, {"target": "lora"}, {"steps": 0}, {"replay_ratio": 1.5}, {"anchor_lambda": -0.1}, {"lr": 0.0}, {"seq_len": 8}):
 
214
  with pytest.raises(ValueError):
215
  SleepConfig(**bad).validate()
216
 
@@ -226,10 +227,11 @@ def test_cli_sleep_dispatches_ttt_records(tmp_path, monkeypatch, capsys):
226
  probes = tmp_path / "p.json"
227
  probes.write_text('[{"question": "q", "answer": "a"}]', encoding="utf-8")
228
  rc = cli.main(["sleep", "chat_m", "--artifacts-root", root, "--method", "distill", "--target", "all", "--steps", "3",
229
- "--sessions", "s1", "s2", "--recall", str(probes), "--device", "cpu"])
230
  assert rc == 0
231
  mid, cfg, kw = calls[-1]
232
  assert mid == "chat_m" and cfg.method == "distill" and cfg.target == "all" and cfg.steps == 3 and cfg.device == "cpu"
 
233
  assert kw["session_ids"] == ["s1", "s2"] and [p.question for p in kw["probes"]] == ["q"]
234
  assert json.loads(capsys.readouterr().out)["model_id"] == "sleep_1"
235
 
 
210
 
211
  def test_sleep_config_validation():
212
  SleepConfig().validate()
213
+ SleepConfig(provenance="all").validate()
214
+ for bad in ({"method": "dream"}, {"target": "lora"}, {"steps": 0}, {"replay_ratio": 1.5}, {"anchor_lambda": -0.1}, {"lr": 0.0}, {"seq_len": 8}, {"provenance": "some"}):
215
  with pytest.raises(ValueError):
216
  SleepConfig(**bad).validate()
217
 
 
227
  probes = tmp_path / "p.json"
228
  probes.write_text('[{"question": "q", "answer": "a"}]', encoding="utf-8")
229
  rc = cli.main(["sleep", "chat_m", "--artifacts-root", root, "--method", "distill", "--target", "all", "--steps", "3",
230
+ "--sessions", "s1", "s2", "--recall", str(probes), "--device", "cpu", "--provenance", "all"])
231
  assert rc == 0
232
  mid, cfg, kw = calls[-1]
233
  assert mid == "chat_m" and cfg.method == "distill" and cfg.target == "all" and cfg.steps == 3 and cfg.device == "cpu"
234
+ assert cfg.provenance == "all"
235
  assert kw["session_ids"] == ["s1", "s2"] and [p.question for p in kw["probes"]] == ["q"]
236
  assert json.loads(capsys.readouterr().out)["model_id"] == "sleep_1"
237