Spaces:
Running on Zero
Running on Zero
File size: 15,241 Bytes
98acb70 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | -- grammar.lua - Constrained decoding by logit masking.
--
-- This is the headline of the repo.
--
-- Tool calling is not a capability that emerges from scale. It is a decoding
-- constraint. At each step the model produces logits over the whole
-- vocabulary; set every token that would break the grammar to -inf before
-- sampling, and the model becomes structurally incapable of emitting a
-- malformed call. A 15M TinyStories checkpoint -- which has never seen JSON
-- and cannot reason -- then emits 100% parseable tool calls.
--
-- The grammar guarantees the SYNTAX. The model supplies the SEMANTICS. That
-- boundary is the whole lesson, and a 15M model puts it in sharp relief:
-- every call parses, and the arguments are nonsense.
--
-- {"tool":"read_file","args":{"path":"the little girl"}}
-- ^ grammar's work ^ model's work
--
--
-- WHY THIS IS HARDER THAN IT LOOKS
--
-- The grammar is defined over characters; it has to be enforced over BPE
-- tokens, and tokens straddle the character boundaries the grammar cares
-- about. `{"` is a single token in Llama's vocabulary. A token is legal only
-- if EVERY character it contributes is legal from the current state -- so the
-- machine has to simulate consuming a whole string, not test one character.
--
-- The second complication is branching: `{"tool":"` is shared by every tool,
-- but after the name the argument keys differ. So the machine is an NFA, not
-- a DFA. It tracks a SET of live states, one per tool path still consistent
-- with what has been emitted. The set collapses to one as soon as the model
-- commits to a name, and stays tiny throughout.
--
--
-- STRUCTURE
--
-- One linear path per tool, built from two segment kinds:
--
-- lit(text) consume exactly this string
-- str consume string-body characters; the closing quote belongs to
-- the NEXT literal, so termination falls out of the NFA rather
-- than needing lookahead
--
-- For calc, the path is:
--
-- lit {"tool":"calc","args":{"expr":"
-- str
-- lit "}}
--
-- There is no number state and no enum state. Numbers are strings on the
-- wire (see protocol.lua), and the tool name is baked into each path's first
-- literal, which is what makes the branching an NFA instead of a special case.
local trace = require('trace')
local grammar = {}
-- A string argument may not run forever. Without a cap the model can hold the
-- machine in the str state indefinitely and never close the call.
grammar.MAX_STRING = 256
-- Characters a JSON string body may contain here. Excluding the backslash as
-- well as the quote means no escape handling: the value is always literal,
-- which keeps both this machine and protocol.parse simple.
local function is_body_char(c)
local b = c:byte()
return b >= 0x20 and c ~= '"' and c ~= '\\'
end
--------------------------------------------------------------------------
-- Compilation
--------------------------------------------------------------------------
-- Precompute the decoded string for every vocabulary entry. Done once: the
-- mask runs on every generated token and cannot afford a tokenizer call.
--
-- Ids 0-2 (unk/BOS/EOS) decode to literal text like "<s>" in the llama2.c
-- tokenizer, which must never be treated as grammar input. They are blanked
-- here and handled explicitly in mask().
function grammar.build_token_table(tokenizer, vocab_size)
local t = {}
for id = 0, vocab_size - 1 do
t[id] = (id <= 2) and "" or (tokenizer:decode(id) or "")
end
return t
end
local function path_for(tool)
local segs = {}
local head = string.format('{"tool":"%s","args":{', tool.name)
local args = tool.args or {}
if #args == 0 then
segs[#segs + 1] = { kind = "lit", text = head .. "}}" }
return segs
end
-- The machine writes the keys itself. The model only ever fills values,
-- which is why a model that has never seen JSON still produces valid
-- JSON: the parts it could get wrong are not its to write.
for i, a in ipairs(args) do
local open = (i == 1) and (head .. '"' .. a.name .. '":"')
or ('","' .. a.name .. '":"')
segs[#segs + 1] = { kind = "lit", text = open }
-- min = 1: a required argument may not be empty.
--
-- Without this the grammar emits calls that are syntactically
-- perfect and guaranteed to fail validation, because tools.lua
-- treats an empty required argument as a missing one. The two
-- modules disagreed about what "well-formed" means and the model
-- sat in the gap: stories42M emitted
-- {"tool":"write_file","args":{"path":"","text":""}} four times
-- running on the denied-write eval task, each rejected by the
-- validator before the approval gate was ever consulted.
--
-- Enforcing it here rather than validating afterwards is the whole
-- premise of this file: a constraint checkable during decoding
-- should be unrepresentable, not caught.
segs[#segs + 1] = { kind = "str", min = 1 }
end
segs[#segs + 1] = { kind = "lit", text = '"}}' }
return segs
end
local Grammar = {}
Grammar.__index = Grammar
-- Compile from the tool registry. Required args only: an optional argument
-- would make the path branch, and a model that cannot reason has no business
-- choosing whether to supply one.
function grammar.compile(tools, tokenizer, vocab_size)
vocab_size = vocab_size or tokenizer.vocab_size
if not vocab_size then error("grammar.compile: need a vocab_size") end
local paths, names = {}, {}
for _, tool in ipairs(tools:list()) do
local required = {}
for _, a in ipairs(tool.args or {}) do
if a.required then required[#required + 1] = a end
end
paths[#paths + 1] = path_for({ name = tool.name, args = required })
names[#names + 1] = tool.name
end
if #paths == 0 then error("grammar.compile: registry is empty") end
return setmetatable({
paths = paths,
names = names,
vocab_size = vocab_size,
token_text = grammar.build_token_table(tokenizer, vocab_size),
mask_cache = {},
}, Grammar)
end
--------------------------------------------------------------------------
-- The machine
--------------------------------------------------------------------------
local Machine = {}
Machine.__index = Machine
-- A live state is { path, seg, pos } where pos counts characters consumed
-- within the current segment. seg > #path means that path has completed.
-- The cache key for a state set.
--
-- Inside a string argument the exact character offset is NOT part of the
-- key. The legal-token set in a str state depends on the offset only through
-- the MAX_STRING cap, so every position far enough from that cap has an
-- identical mask -- and collapsing them is the difference between a 32000-
-- token rescan per generated token and a table lookup.
--
-- The margin must exceed the longest token in the vocabulary, since `consume`
-- walks a whole token's characters and a token near the cap can straddle it.
-- Llama's longest pieces are well under 32 bytes.
--
-- This only coarsens the CACHE KEY. The machine's real positions stay exact,
-- so nothing about the accepted language changes.
--
-- Two positions may share a key only if their legal-token sets are identical.
-- That rules out any offset below the segment's `min`, where the closing
-- quote is still illegal -- bucketing pos=0 together with pos=5 under a
-- min of 1 would hand the model a mask that lets it close an empty value.
local STR_BUCKET_MARGIN = 32
local function state_key(g, states)
local parts = {}
for _, s in ipairs(states) do
local pos = s.pos
local seg = g.paths[s.path][s.seg]
if seg and seg.kind == "str"
and pos >= (seg.min or 0)
and pos <= grammar.MAX_STRING - STR_BUCKET_MARGIN then
pos = "s"
end
parts[#parts + 1] = s.path .. ":" .. s.seg .. ":" .. tostring(pos)
end
table.sort(parts)
return table.concat(parts, "|")
end
-- Advance one state by one character, appending every resulting state to
-- `out`. A str state produces two successors on a quote: the body cannot
-- contain one, so the only reading is that the value ended.
local function step_state(g, s, c, out)
local path = g.paths[s.path]
local seg = path[s.seg]
if not seg then return end -- already complete; nothing follows
if seg.kind == "lit" then
if seg.text:sub(s.pos + 1, s.pos + 1) == c then
local pos = s.pos + 1
if pos == #seg.text then
out[#out + 1] = { path = s.path, seg = s.seg + 1, pos = 0 }
else
out[#out + 1] = { path = s.path, seg = s.seg, pos = pos }
end
end
return
end
-- str: stay in the body, or let the following literal claim this char
if s.pos < grammar.MAX_STRING and is_body_char(c) then
out[#out + 1] = { path = s.path, seg = s.seg, pos = s.pos + 1 }
end
-- The value may only end once it has met its minimum length, so a
-- required argument cannot be closed empty.
if s.pos >= (seg.min or 0) then
local nxt = path[s.seg + 1]
if nxt and nxt.kind == "lit" and nxt.text:sub(1, 1) == c then
if #nxt.text == 1 then
out[#out + 1] = { path = s.path, seg = s.seg + 2, pos = 0 }
else
out[#out + 1] = { path = s.path, seg = s.seg + 1, pos = 1 }
end
end
end
end
-- Consume a whole token's text. Returns the resulting state set, or nil if
-- the token is illegal from `states`.
local function consume(g, states, text)
local cur = states
for i = 1, #text do
local nxt = {}
local c = text:sub(i, i)
for _, s in ipairs(cur) do step_state(g, s, c, nxt) end
if #nxt == 0 then return nil end
-- Deduplicate: shared prefixes across tool paths would otherwise
-- multiply the set on every character.
local seen, uniq = {}, {}
for _, s in ipairs(nxt) do
local k = s.path .. ":" .. s.seg .. ":" .. s.pos
if not seen[k] then seen[k] = true uniq[#uniq + 1] = s end
end
cur = uniq
end
return cur
end
-- Fresh machine positioned at the start of a call, live on every tool path.
function Grammar:start()
local states = {}
for i = 1, #self.paths do
states[i] = { path = i, seg = 1, pos = 0 }
end
return setmetatable({ g = self, states = states, emitted = {} }, Machine)
end
function Machine:is_done()
for _, s in ipairs(self.states) do
if s.seg > #self.g.paths[s.path] then return true end
end
return false
end
-- The legal-token set for the current state, as a list of token ids.
-- Cached: the machine revisits the same literal positions on every call, so
-- after warmup this is a table lookup rather than a 32000-token scan.
function Machine:legal_ids(vocab_size)
local g = self.g
local key = state_key(g, self.states)
local cached = g.mask_cache[key]
if not cached then
cached = {}
for id = 0, vocab_size - 1 do
local text = g.token_text[id]
if text ~= "" and consume(g, self.states, text) then
cached[#cached + 1] = id
end
end
g.mask_cache[key] = cached
end
return cached
end
-- Mask logits in place: every token illegal from the current state becomes
-- -inf, and legal ones keep the value the model produced. Returns how many
-- are left legal.
--
-- Preserving the values matters: masking must REMOVE FROM the model's
-- distribution, never flatten it. Zeroing the survivors would make every
-- legal token equally likely and throw away the only judgement the model is
-- contributing.
--
-- The returned count is worth logging. While the machine is writing
-- {"tool":" it drops to 1 -- the grammar is dictating and the model
-- contributes nothing. Inside an argument value it jumps to thousands, which
-- is exactly where the model's judgement is the only thing operating.
-- Watching that number rise and fall is watching the syntax/semantics
-- boundary move in real time.
function Machine:apply(logits, vocab_size)
vocab_size = vocab_size or self.g.vocab_size
-- Once complete, EOS is the only continuation.
if self:is_done() then
local saved = logits[3] -- token id 2, 1-indexed
for i = 1, vocab_size do logits[i] = -math.huge end
logits[3] = saved
return 1
end
local cached = self:legal_ids(vocab_size)
local keep = {}
for _, id in ipairs(cached) do keep[id + 1] = logits[id + 1] end
for i = 1, vocab_size do logits[i] = -math.huge end
for i, v in pairs(keep) do logits[i] = v end
return #cached
end
-- Advance the machine by an accepted token.
function Machine:advance(token_id)
local text = self.g.token_text[token_id]
if not text or text == "" then return end
local nxt = consume(self.g, self.states, text)
if not nxt then
-- Only reachable if a caller sampled a token the mask excluded.
error(string.format(
"grammar: token %d (%q) is not legal here -- was the mask applied?",
token_id, text), 0)
end
self.states = nxt
self.emitted[#self.emitted + 1] = text
end
function Machine:text()
return table.concat(self.emitted)
end
-- Force the machine to completion, returning the characters still owed.
-- Used when generation hits its token ceiling mid-call: finishing the call
-- costs a few characters and turns a wasted step into a usable one.
function Machine:finish()
local best
for _, s in ipairs(self.states) do
if not best or s.path < best.path then best = s end
end
if not best then return "" end
local path, out = self.g.paths[best.path], {}
local seg, pos = best.seg, best.pos
while seg <= #path do
local sg = path[seg]
if sg.kind == "lit" then
out[#out + 1] = sg.text:sub(pos + 1)
elseif sg.min and pos < sg.min then
-- A forced finish must still satisfy the minimum length, or it
-- produces exactly the empty-argument call the min exists to
-- prevent. "?" is a deliberate tell: the value was cut short by
-- the token ceiling, not chosen.
out[#out + 1] = string.rep("?", sg.min - pos)
end
seg, pos = seg + 1, 0
end
return table.concat(out)
end
-- Convenience for the ablation: does this text parse as a tool call?
function grammar.parses(text)
local v = trace.decode(text)
return type(v) == "table" and type(v.tool) == "string"
end
return grammar
|