Spaces:
Sleeping
Sleeping
Commit ·
4252956
1
Parent(s): 70808b3
sampler mask from blacklist added.
Browse files- app.py +14 -1
- assets/lilylet-blacklist.json +37 -0
- lilyscript/generator.py +82 -9
- lilyscript/mask_monitor.py +128 -0
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 |
-
|
| 26 |
-
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|