sidechat / logits.py
lsb's picture
Port steganacrostics to a Gradio app; retarget to MiniCPM5-1B
08b2dd2 verified
Raw
History Blame
2.86 kB
"""Grammar-constrained LogitsProcessor (Python port of src/logits.js).
At each generation step:
1. Decode the generated suffix back to text.
2. Advance the grammar NFA by that text.
3. For every candidate token id, check whether appending its decoded text
keeps the NFA alive; mask losers to -inf (via the shared LegalCache).
4. EOS is allowed only once the NFA has reached an accept state.
Per-token decoding can disagree with BPE sequence-decoding in edge cases
(merged punctuation, etc.); for the acrostic patterns we care about this
approximation is fine.
"""
from __future__ import annotations
import time
from transformers import LogitsProcessor
from masking import LegalCache
def build_token_text_table(tokenizer, vocab_size):
"""One-shot build of tokenId -> text, using per-token decode. Special tokens
decode to '' under skip_special_tokens=True, which we treat as
"disallowed" (empty string)."""
texts = tokenizer.batch_decode(
[[i] for i in range(vocab_size)], skip_special_tokens=True
)
return [t if isinstance(t, str) else "" for t in texts]
class GrammarLogitsProcessor(LogitsProcessor):
def __init__(self, grammar, tokenizer, token_text, eos_token_ids=(), legal_cache=None):
super().__init__()
self.grammar = grammar
self.tokenizer = tokenizer
self.token_text = token_text
self.cache = legal_cache or LegalCache(grammar, token_text, eos_token_ids)
self.prompt_length = None
self.stats = _fresh_stats()
def reset(self):
self.prompt_length = None
self.stats = _fresh_stats()
def __call__(self, input_ids, scores):
t_entry = time.perf_counter()
ids = input_ids[0]
if self.prompt_length is None:
self.prompt_length = ids.shape[0]
generated = ids[self.prompt_length:].tolist()
text = (
self.tokenizer.decode(generated, skip_special_tokens=True)
if generated
else ""
)
state = self.grammar.advance(self.grammar.initial, text)
data = scores[0]
if state == -1:
# Already violated; nothing useful to do without rewinding. Let the
# original logits through so generation at least terminates.
self._record(time.perf_counter() - t_entry, -1)
return scores
illegal = self.cache.illegal_tensor(state)
data[illegal.to(data.device)] = float("-inf")
self._record(time.perf_counter() - t_entry, int((~illegal).sum().item()))
return scores
def _record(self, dt, survivors):
st = self.stats
st["calls"] += 1
st["total_ms"] += dt * 1000.0
st["per_step"].append({"ms": dt * 1000.0, "survivors": survivors})
def _fresh_stats():
return {"calls": 0, "total_ms": 0.0, "per_step": []}