k-l-lambda commited on
Commit
4252956
·
1 Parent(s): 70808b3

sampler mask from blacklist added.

Browse files
app.py CHANGED
@@ -26,6 +26,7 @@ import gradio as gr
26
 
27
  from lilyscript.generator import StreamingLilyletGenerator
28
  from lilyscript.postprocess import postprocess
 
29
 
30
  HERE = os.path.dirname(os.path.abspath(__file__))
31
  # Model weights are pulled from the HuggingFace model repo `k-l-lambda/LilyNota`
@@ -53,6 +54,13 @@ INSTRUMENTS = _STYLES['instruments']
53
 
54
  _GEN = None
55
 
 
 
 
 
 
 
 
56
 
57
  # ---- system log capture ----------------------------------------------------
58
  # A ring buffer that mirrors Python logging (lifecycle messages, warnings, and
@@ -124,6 +132,8 @@ def get_generator ():
124
  t0 = time.perf_counter()
125
  _GEN = StreamingLilyletGenerator(model_dir, ASSET_DIR)
126
  LOG.info('generator ready (%.1fs)', time.perf_counter() - t0)
 
 
127
  return _GEN
128
 
129
 
@@ -231,12 +241,15 @@ def run_generation (prompt, measures, temperature, max_patches, seed, store, top
231
  t0 = time.perf_counter()
232
  try:
233
  gen = get_generator()
 
 
 
234
  # drop a marker in the log timeline; _log_panel expands it to the live output,
235
  # so subsequent log lines (timing/done) land *after* the generation text.
236
  LOG.info(_GEN_MARKER)
237
  for raw, pretty, done in gen.generate_stream(
238
  prompt_text=prompt or '', max_patches=mp, temperature=float(temperature),
239
- top_k=int(top_k), top_p=float(top_p), measures=meas, seed=int(seed)):
240
  if not done:
241
  n_yields += 1
242
  # progress on the Generate button label: by measures (completed `|`
 
26
 
27
  from lilyscript.generator import StreamingLilyletGenerator
28
  from lilyscript.postprocess import postprocess
29
+ from lilyscript.mask_monitor import MaskMonitor, load_blacklist
30
 
31
  HERE = os.path.dirname(os.path.abspath(__file__))
32
  # Model weights are pulled from the HuggingFace model repo `k-l-lambda/LilyNota`
 
54
 
55
  _GEN = None
56
 
57
+ # Syntax-blacklist mask: discovered 2-grams whose forbidden next-tokens are masked
58
+ # during sampling so the model can't emit those locally-illegal continuations.
59
+ # Loaded once at startup; a fresh (stateful) MaskMonitor is built per generation.
60
+ # Empty/missing file -> no masking (behavior unchanged).
61
+ BLACKLIST_PATH = os.path.join(ASSET_DIR, 'lilylet-blacklist.json')
62
+ _BLACKLIST = load_blacklist(BLACKLIST_PATH)
63
+
64
 
65
  # ---- system log capture ----------------------------------------------------
66
  # A ring buffer that mirrors Python logging (lifecycle messages, warnings, and
 
132
  t0 = time.perf_counter()
133
  _GEN = StreamingLilyletGenerator(model_dir, ASSET_DIR)
134
  LOG.info('generator ready (%.1fs)', time.perf_counter() - t0)
135
+ LOG.info('syntax blacklist: %d contexts / %d forbidden pairs from %s',
136
+ len(_BLACKLIST), sum(len(v) for v in _BLACKLIST.values()), BLACKLIST_PATH)
137
  return _GEN
138
 
139
 
 
241
  t0 = time.perf_counter()
242
  try:
243
  gen = get_generator()
244
+ # build a fresh per-run mask monitor (stateful: tracks the running 2-gram).
245
+ # None when no blacklist is loaded -> sampling path unchanged.
246
+ monitor = MaskMonitor(gen, _BLACKLIST) if _BLACKLIST else None
247
  # drop a marker in the log timeline; _log_panel expands it to the live output,
248
  # so subsequent log lines (timing/done) land *after* the generation text.
249
  LOG.info(_GEN_MARKER)
250
  for raw, pretty, done in gen.generate_stream(
251
  prompt_text=prompt or '', max_patches=mp, temperature=float(temperature),
252
+ top_k=int(top_k), top_p=float(top_p), measures=meas, seed=int(seed), monitor=monitor):
253
  if not done:
254
  n_yields += 1
255
  # progress on the Generate button label: by measures (completed `|`
assets/lilylet-blacklist.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "meta": {
3
+ "temperature": 1.5,
4
+ "top_p": 0.995,
5
+ "top_k": 0,
6
+ "seed": 0,
7
+ "restarts": 50,
8
+ "measures": -1,
9
+ "tokens": 43039,
10
+ "oracle_calls": 20294,
11
+ "elapsed_s": 403.4,
12
+ "model_dir": "/home/camus/data/models/LilyNota/onnx"
13
+ },
14
+ "blacklist": {
15
+ "93": [
16
+ 44,
17
+ 46,
18
+ 126
19
+ ],
20
+ "167": [
21
+ 167,
22
+ 168
23
+ ],
24
+ "226": [
25
+ 44,
26
+ 45,
27
+ 46,
28
+ 52
29
+ ],
30
+ "244": [
31
+ 44
32
+ ],
33
+ "248,202,34,124": [
34
+ 46
35
+ ]
36
+ }
37
+ }
lilyscript/generator.py CHANGED
@@ -22,9 +22,25 @@ import onnxruntime as ort
22
  from .postprocess import postprocess
23
 
24
 
25
- def sample_next (logits, rng, temperature=1.0, top_k=0, top_p=1.0):
26
- '''Sample one token id from a logits vector (numpy) with temperature/top-k/top-p.'''
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  logits = logits.astype(np.float64)
 
 
 
28
  if temperature != 1.0:
29
  logits = logits / max(temperature, 1e-6)
30
  if top_k and top_k > 0:
@@ -136,11 +152,24 @@ class StreamingLilyletGenerator:
136
  new_past.append(out[f'new_v_{i}'])
137
  return out['logits'][0, -1], new_past
138
 
139
- def _generate_patch (self, last_hidden, rng, prefix_ids=None, temperature=1.0, top_k=0, top_p=1.0):
 
140
  '''Token-level decode for one patch through the token-KV session: feed the
141
- patch state, then bos+prefix embeddings, then sample until the patch is full.'''
 
 
 
 
 
 
 
 
 
142
  generated = list(prefix_ids or [])
143
  tokens = [self.bos_id] + list(prefix_ids or [])
 
 
 
144
  past = self._empty_token_past()
145
  enc = last_hidden.reshape(1, 1, -1).astype(np.float32)
146
  logits, past = self._token_kv_step(enc, past)
@@ -148,7 +177,24 @@ class StreamingLilyletGenerator:
148
  emb = self.wte[tokens[i]].reshape(1, 1, -1)
149
  logits, past = self._token_kv_step(emb, past)
150
  while len(generated) < self.patch_size:
151
- nxt = sample_next(logits, rng, temperature=temperature, top_k=top_k, top_p=top_p)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  generated.append(nxt)
153
  if len(generated) < self.patch_size:
154
  emb = self.wte[nxt].reshape(1, 1, -1)
@@ -156,13 +202,19 @@ class StreamingLilyletGenerator:
156
  return generated
157
 
158
  def generate_stream (self, prompt_text='', max_patches=256, temperature=1.0, top_k=0,
159
- top_p=0.9, measures=None, seed=0):
160
  '''Autoregressive generation, yielding after every patch.
161
 
162
  Yields (raw, pretty, done):
163
  raw -- accumulated decoded patch text (with `[r:x/y]` markers), for the log
164
  pretty -- postprocess(raw), measure-segmented, for the editor
165
  done -- True on the final yield (EOS patch or max_patches reached)
 
 
 
 
 
 
166
  '''
167
  rng = np.random.default_rng(seed)
168
 
@@ -179,6 +231,12 @@ class StreamingLilyletGenerator:
179
  prime_ids = self.tokenizer.encode(f'[r:0/{measures}]') if measures is not None else None
180
  primed = False
181
 
 
 
 
 
 
 
182
  # prefill: run all seed patches through the patch-KV decoder in one call
183
  past = self._empty_patch_past()
184
  last, past = self._patch_kv_step(patches, past)
@@ -186,14 +244,29 @@ class StreamingLilyletGenerator:
186
  yield out_text, postprocess(out_text), False
187
 
188
  for _ in range(max_patches):
189
- patch_ids = self._generate_patch(last, rng, temperature=temperature, top_k=top_k, top_p=top_p)
 
 
 
 
 
 
 
 
 
190
 
191
  # first time the model emits a stream patch, re-sample with the forced
192
  # `[r:0/<measures>]` prefix so the body starts at the requested measure count.
193
- if prime_ids is not None and not primed and self.patch_to_text(patch_ids).startswith('[r:'):
 
 
 
 
194
  primed = True
 
 
195
  patch_ids = self._generate_patch(last, rng, prefix_ids=prime_ids,
196
- temperature=temperature, top_k=top_k, top_p=top_p)
197
 
198
  # EOS patch -> done
199
  if patch_ids[0] == self.bos_id and patch_ids[1] == self.eos_id:
 
22
  from .postprocess import postprocess
23
 
24
 
25
+ class _StreamAborted (Exception):
26
+ '''Raised by the token loop when a syntax monitor exhausts its redraw budget,
27
+ signalling generate_stream to end the stream cleanly rather than commit a bad
28
+ token (which would corrupt the monitor's running text). Internal to the
29
+ blacklist-discovery path; never raised when monitor is None.'''
30
+ pass
31
+
32
+
33
+ def sample_next (logits, rng, temperature=1.0, top_k=0, top_p=1.0, banned_ids=None):
34
+ '''Sample one token id from a logits vector (numpy) with temperature/top-k/top-p.
35
+
36
+ banned_ids: optional iterable of token ids to forbid; their logits are set to
37
+ -inf before any other filtering, so they can never be drawn (used by the
38
+ syntax-blacklist mask in tools/lilylet_blacklist_gen.py). Default None = no-op.
39
+ '''
40
  logits = logits.astype(np.float64)
41
+ if banned_ids:
42
+ logits = logits.copy()
43
+ logits[list(banned_ids)] = -np.inf
44
  if temperature != 1.0:
45
  logits = logits / max(temperature, 1e-6)
46
  if top_k and top_k > 0:
 
152
  new_past.append(out[f'new_v_{i}'])
153
  return out['logits'][0, -1], new_past
154
 
155
+ def _generate_patch (self, last_hidden, rng, prefix_ids=None, temperature=1.0, top_k=0, top_p=1.0,
156
+ monitor=None, max_redraws=16):
157
  '''Token-level decode for one patch through the token-KV session: feed the
158
+ patch state, then bos+prefix embeddings, then sample until the patch is full.
159
+
160
+ monitor: optional syntax-blacklist hook (default None -> unchanged behavior).
161
+ - monitor.banned() -> iterable of token ids to mask for the next draw,
162
+ - monitor.accept(id) -> bool: True to commit the drawn token, False to
163
+ redraw (the monitor records the violation + grows its ban set internally),
164
+ - monitor.commit_forced(id): notified of each forced (non-sampled) token so
165
+ its running context/stream stay correct.
166
+ Forced tokens (bos + prefix_ids) bypass accept()/redraw — only commit_forced.
167
+ '''
168
  generated = list(prefix_ids or [])
169
  tokens = [self.bos_id] + list(prefix_ids or [])
170
+ if monitor is not None:
171
+ for tid in prefix_ids or []:
172
+ monitor.commit_forced(tid)
173
  past = self._empty_token_past()
174
  enc = last_hidden.reshape(1, 1, -1).astype(np.float32)
175
  logits, past = self._token_kv_step(enc, past)
 
177
  emb = self.wte[tokens[i]].reshape(1, 1, -1)
178
  logits, past = self._token_kv_step(emb, past)
179
  while len(generated) < self.patch_size:
180
+ if monitor is None:
181
+ nxt = sample_next(logits, rng, temperature=temperature, top_k=top_k, top_p=top_p)
182
+ else:
183
+ # draw with the current ban mask; on a rejected (illegal) token the
184
+ # monitor records it and we redraw from the SAME logits with the now-
185
+ # larger mask. The token KV cache is only advanced for the committed
186
+ # token below, so redrawing here costs nothing.
187
+ for _ in range(max_redraws):
188
+ nxt = sample_next(logits, rng, temperature=temperature, top_k=top_k, top_p=top_p,
189
+ banned_ids=monitor.banned())
190
+ if monitor.accept(nxt):
191
+ break
192
+ else:
193
+ # every redraw was rejected. Committing a bad token would corrupt
194
+ # the monitor's running text (every subsequent token would then
195
+ # parse-fail and be mis-recorded), so abort the stream cleanly
196
+ # instead — the caller (generate_stream) ends generation here.
197
+ raise _StreamAborted()
198
  generated.append(nxt)
199
  if len(generated) < self.patch_size:
200
  emb = self.wte[nxt].reshape(1, 1, -1)
 
202
  return generated
203
 
204
  def generate_stream (self, prompt_text='', max_patches=256, temperature=1.0, top_k=0,
205
+ top_p=0.9, measures=None, seed=0, monitor=None):
206
  '''Autoregressive generation, yielding after every patch.
207
 
208
  Yields (raw, pretty, done):
209
  raw -- accumulated decoded patch text (with `[r:x/y]` markers), for the log
210
  pretty -- postprocess(raw), measure-segmented, for the editor
211
  done -- True on the final yield (EOS patch or max_patches reached)
212
+
213
+ monitor: optional syntax-blacklist hook threaded into the token decode loop
214
+ (default None -> behavior identical to before). When a monitor is given the
215
+ `[r:0/<measures>]` priming re-sample is disabled: priming is a probe-then-
216
+ discard draw that would corrupt the monitor's running stream, and the
217
+ blacklist harness wants free generation anyway.
218
  '''
219
  rng = np.random.default_rng(seed)
220
 
 
231
  prime_ids = self.tokenizer.encode(f'[r:0/{measures}]') if measures is not None else None
232
  primed = False
233
 
234
+ # seed the monitor's running context/stream from the prompt patches (if any)
235
+ if monitor is not None and len(patches) > 1:
236
+ for p in patches[1:]:
237
+ for tid in p:
238
+ monitor.commit_forced(tid)
239
+
240
  # prefill: run all seed patches through the patch-KV decoder in one call
241
  past = self._empty_patch_past()
242
  last, past = self._patch_kv_step(patches, past)
 
244
  yield out_text, postprocess(out_text), False
245
 
246
  for _ in range(max_patches):
247
+ # If a monitor supports priming-rewind (mark/rollback), snapshot before the
248
+ # draw so a discarded probe patch can be undone (see priming below).
249
+ can_prime_with_monitor = monitor is not None and hasattr(monitor, 'mark')
250
+ if can_prime_with_monitor:
251
+ monitor.mark()
252
+ try:
253
+ patch_ids = self._generate_patch(last, rng, temperature=temperature, top_k=top_k, top_p=top_p,
254
+ monitor=monitor)
255
+ except _StreamAborted:
256
+ break
257
 
258
  # first time the model emits a stream patch, re-sample with the forced
259
  # `[r:0/<measures>]` prefix so the body starts at the requested measure count.
260
+ # With a priming-capable monitor we rewind its running text first, so the
261
+ # discarded probe patch doesn't pollute the 2-gram context. (The discovery
262
+ # harness's monitor lacks mark/rollback, so priming stays disabled there.)
263
+ if prime_ids is not None and not primed and self.patch_to_text(patch_ids).startswith('[r:') \
264
+ and (monitor is None or can_prime_with_monitor):
265
  primed = True
266
+ if can_prime_with_monitor:
267
+ monitor.rollback()
268
  patch_ids = self._generate_patch(last, rng, prefix_ids=prime_ids,
269
+ temperature=temperature, top_k=top_k, top_p=top_p, monitor=monitor)
270
 
271
  # EOS patch -> done
272
  if patch_ids[0] == self.bos_id and patch_ids[1] == self.eos_id:
lilyscript/mask_monitor.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime syntax-blacklist mask for live generation.
2
+
3
+ A lightweight, parse-free counterpart to tools/lilylet_blacklist_gen.py's
4
+ BlacklistMonitor. It does NOT call any parser/oracle and has no Node dependency:
5
+ it simply trusts a pre-discovered 2-gram blacklist and masks the forbidden next
6
+ tokens during sampling. Suitable for the Gradio app's live generation path.
7
+
8
+ Wire-compatible with StreamingLilyletGenerator's `monitor` hook:
9
+ banned() -> ids to mask for the next draw (blacklist[current 2-gram])
10
+ accept(id)->bool -> always True (we trust the mask; never re-parse)
11
+ commit_forced(id) -> advance running text/context for a forced token
12
+ Plus mark()/rollback() so the generator's `[r:0/<measures>]` priming re-sample
13
+ (a probe-then-discard draw) can rewind the running text before the forced redraw.
14
+
15
+ The 2-gram context is the last two CONTENT token ids of the marker-stripped
16
+ stream (markers aren't real Lilylet), recomputed from a bounded text suffix so it
17
+ "sees through" `[r:x/y]` markers exactly as the discovery monitor did.
18
+ """
19
+
20
+ import os
21
+ import re
22
+ import json
23
+
24
+ # A complete stream marker `[r:<digits>/<digits>]`; a trailing partial `[r…`.
25
+ # (Mirror tools/lilylet_blacklist_gen.py: only strip a confirmed `[r` prefix, never
26
+ # a bare `[`, which is real content — a header `[composer …]` or beam `c8[`.)
27
+ _MARKER_COMPLETE = re.compile(r'\[r:\d+/\d*\]')
28
+ _MARKER_PARTIAL_TAIL = re.compile(r'\[r(:(\d+(/\d*)?)?)?$')
29
+
30
+
31
+ def _clean (raw):
32
+ return _MARKER_PARTIAL_TAIL.sub('', _MARKER_COMPLETE.sub('', raw))
33
+
34
+
35
+ def _whitespace_ids (tokenizer):
36
+ '''Token ids for space/newline/tab/CR present in the vocab — dropped from the
37
+ content context so it matches the corpus index space the blacklist was keyed in.'''
38
+ out = []
39
+ for ch in (' ', '\n', '\t', '\r'):
40
+ enc = tokenizer.encode(ch)
41
+ if len(enc) == 1:
42
+ out.append(enc[0])
43
+ return out
44
+
45
+
46
+ def load_blacklist (path):
47
+ '''Load a blacklist JSON ({"id1,...,idn": [ids...]} under a top-level "blacklist"
48
+ key, or bare) into dict[tuple(int,...) -> set[int]]. Keys are variable-length
49
+ context n-grams. Missing file -> {}.'''
50
+ if not path or not os.path.isfile(path):
51
+ return {}
52
+ data = json.load(open(path))
53
+ raw = data.get('blacklist', data) if isinstance(data, dict) else {}
54
+ bl = {}
55
+ for key, ids in raw.items():
56
+ ctx = tuple(int(x) for x in key.split(',')) if key else ()
57
+ bl[ctx] = set(int(i) for i in ids)
58
+ return bl
59
+
60
+
61
+ class MaskMonitor:
62
+ '''Parse-free runtime mask. Construct with the generator (for tokenizer +
63
+ special ids) and a variable-length-keyed blacklist dict; pass as `monitor=` to
64
+ generate_stream. A key (a,b,c) masks its forbidden tokens whenever the running
65
+ content context ends with the sequence a,b,c (longest/any suffix match).'''
66
+
67
+ def __init__ (self, gen, blacklist):
68
+ self.tk = gen.tokenizer
69
+ self.blacklist = blacklist or {}
70
+ self.pad_id, self.bos_id, self.eos_id = gen.pad_id, gen.bos_id, gen.eos_id
71
+ self._ws = set(_whitespace_ids(self.tk))
72
+ self._key_lengths = sorted({len(k) for k in self.blacklist}, reverse=True) if self.blacklist else []
73
+ self._max_ctx = max(self._key_lengths) if self._key_lengths else 0
74
+ self.raw = ''
75
+ self._ctx_ids = [] # last <=_max_ctx content non-whitespace ids
76
+ self._mark = None
77
+
78
+ def _is_content (self, tid):
79
+ return tid not in (self.pad_id, self.bos_id, self.eos_id)
80
+
81
+ def _is_ctx (self, tid):
82
+ return self._is_content(tid) and int(tid) not in self._ws
83
+
84
+ def _text (self, tid):
85
+ tid = int(tid)
86
+ return '' if not self._is_content(tid) else self.tk.text_by_id.get(tid, '')
87
+
88
+ def _sync_ctx (self):
89
+ clean = _clean(self.raw)
90
+ ids = self.tk.encode(clean[-256:])
91
+ ctx = [i for i in ids if self._is_ctx(i)]
92
+ self._ctx_ids = ctx[-self._max_ctx:] if self._max_ctx else []
93
+
94
+ # ---- generator-facing API ----
95
+
96
+ def banned (self):
97
+ '''Union forbidden sets of every stored key that is a suffix of the context.'''
98
+ if not self._key_lengths:
99
+ return ()
100
+ ctx = self._ctx_ids
101
+ out = set()
102
+ for n in self._key_lengths:
103
+ if n <= len(ctx):
104
+ hit = self.blacklist.get(tuple(ctx[-n:]))
105
+ if hit:
106
+ out |= hit
107
+ return out
108
+
109
+ def accept (self, tid):
110
+ # trust the mask: a non-banned draw is always committed (no re-parse)
111
+ self.commit_forced(tid)
112
+ return True
113
+
114
+ def commit_forced (self, tid):
115
+ self.raw += self._text(tid)
116
+ self._sync_ctx()
117
+
118
+ # ---- priming support (probe-then-discard rewind) ----
119
+
120
+ def mark (self):
121
+ '''Remember the current running text so a discarded probe patch can be undone.'''
122
+ self._mark = self.raw
123
+
124
+ def rollback (self):
125
+ '''Rewind to the last mark() (drops a probe patch's effect on the context).'''
126
+ if self._mark is not None:
127
+ self.raw = self._mark
128
+ self._sync_ctx()