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