Spaces:
Running on Zero
Running on Zero
Download grammar.lua from jeorgexyz/lua-agent-lab: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/grammar.lua
- Command line
-
hf download hf://spaces/jeorgexyz/lua-agent-lab/grammar.lua
-
curl -L -o grammar.lua https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/grammar.lua
15.2 kB
| -- 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 | |