Isaac Breen

llguidance HATES This One Weird Grammar

How llguidance's one-byte lexer lookahead can lose a valid token boundary.

Consider this grammar:

%llguidance {"no_forcing": true}
start: STEM SUFFIX

STEM: "list" | "listen"
SUFFIX: "ed"

The grammar has two terminals. STEM matches either list or listen, and SUFFIX matches ed. The shortest accepted input splits like this:

listSTEMedSUFFIX

Together, those lexemes form listed. The longer stem gives us listened.

llguidance rejects even the shorter one:

>>> b"listed" in next_token_mask
False
>>> validate_tokens([b"listed"])
0
>>> consume_token(b"listed")
False

How llguidance builds a mask

llguidance generates a token mask by walking a trie containing the byte strings in the model vocabulary. As it descends through the trie, it speculatively feeds bytes to a recognizer. A failed byte prunes the trie branch. Reaching a terminal node while the recognizer is still viable adds that vocabulary token to the mask.

Inside that recognizer is a lexer that feeds into an Earley parser. The lexer consumes bytes until it has a completed lexeme. The parser then consumes that lexeme and advances to a new state.

Sounds simple so far, right? And it is. But unfortunately it won’t be.

Before I continue let me just say I think llguidance is pretty great. It solves an important problem in a really elegant way. It's super fast. And all around probably the best constrained decoding framework right now. Maybe it'll get replaced one day by something better. But I'd be willing to bet that that thing will be heavily based on llguidance. So I think I can say pretty safely that this overall approach to grammar-constrained generation will stand the test of time.

Now without further ado, let's DELVE into llguidance's weird LEXER SEMANTICS.

Greedy lexing

Suppose the lexer is reading STEM = "list" | "listen". And suppose the full input string is:

listened

The lexer reads this as:

listenSTEMedSUFFIX

Which parses. And lo, all is well. Or is it?

But remember, llguidance traverses a trie, not a full input string.

Be llguidance for a moment. Traverse the trie. First l. Then i. Then s. Then t.

We’ve seen four bytes: list.

You check the lexer. A match!

But wait. It’s not that simple.

The match is conditional.

This trie node represents the complete LLM token "list". But it also represents "liste...", "lists...", and whatever other LLM tokens happen to begin with "list".

The condition this match is predicated on is that there is not a longer match of the same terminal, STEM. If the next bytes are en, the greedy match is listen, not list.

So, uh, that’s a problem.

We can’t just look ahead. There are multiple possible aheads. This is a trie, which branches out into multiple possibilities.

What about backtracking within the same LLM token? In principle, llguidance could retain earlier accepting lexer and parser states, then try a different lexing if the longer greedy match later fails. Doing that while walking the token trie may add substantial overhead.

The other option is to accept conditionally: let the parser consume the terminal, but tag that parser state with a condition saying that it is valid only if we don’t later find a longer match for the same terminal. But that… uh, oh, actually, I guess that does work. In fact, that’s exactly what GLRMask’s dynamic mode does, directly inspired by this insight. But let’s stick to llguidance.

llguidance takes a simpler path. It effectively looks ahead, but only by one byte. And it turns out that for virtually every grammar that matters, this is not a problem. In fact, it’s hard to come up with a real counterexample. So naturally, the English word listed is one.

For listened, greediness makes the right choice. For listed, it does not. After list, the next byte e might begin the suffix ed, or it might continue the stem towards listen. llguidance keeps reading the stem:

list      accepting
liste     live, non-accepting
listed    dead

The d proves that the correct split was list | ed. But by then the accepting boundary after list is two bytes behind.

Easy fix

In the rare case where this is a problem - and there are some - it can be pretty easily resolved by moving the choice into the parser. For our grammar, lowercase stem is a parser rule rather than a terminal:

%llguidance {"no_forcing": true}
start: stem SUFFIX

stem: "list" | "listen"
SUFFIX: "ed"

BEHOLD:

>>> b"listed" in next_token_mask
True
>>> validate_tokens([b"listed"])
1
>>> consume_token(b"listed")
True

JSON is usually safe

Even this repair is rarely necessary. For the largest category of real-life grammar constraints used in generation - of which 99.9% is almost certainly JSON Schema - the issue barely arises.

JSON is unusually friendly to one-byte lookahead. Strings are the clearest case: the closing quote marks the boundary explicitly, so there is no ambiguity about where the string ends.

The other JSON lexemes are similarly well behaved in context. Numbers, true, false, and null are followed by punctuation, whitespace, or the end of input, none of which can continue the preceding lexeme. Even the awkward number path 11e1e2 is harmless: e can continue a number after 1, but it cannot begin anything legal after a completed number. Regex constraints inside strings still end at the closing quote. Any genuinely awkward custom boundary can usually be fixed by splitting the terminal there.

Context matters

It would be nice if we could inspect each terminal definition independently and say: this terminal obeys some rule, therefore the lexer will behave well around it. We cannot. A terminal’s behaviour also depends on the other terminals that the parser permits beside it.

That is not peculiar to llguidance. Lark’s contextual lexer also uses parser state to decide which terminals are active. Here NAME and VALUE have identical definitions:

start: "name:" NAME
     | "value:" VALUE

NAME: /[a-z]+/
VALUE: /[a-z]+/

%ignore /[ \t]+/

Yet the same bytes become different tokens in different parser contexts:

>>> parser.parse("name: abc").children[0]
Token('NAME', 'abc')
>>> parser.parse("value: abc").children[0]
Token('VALUE', 'abc')

Lark depends on parser context too, but it does not have llguidance’s one-byte lookahead limitation. The opening grammar parses normally:

>>> Lark(grammar, parser="lalr").parse("listed")
Tree(Token('RULE', 'start'), [Token('STEM', 'list'), Token('SUFFIX', 'ed')])

Parser context can nevertheless make llguidance’s behaviour stranger still:

start: "ping " ipv6
     | "connect " (ipv6 | HOST_PORT)

ipv6: HEX "::" HEX

HEX: /[0-9a-f]+/
HOST_PORT: /[a-z0-9]+:[0-9]+/

The grammar accepts an IPv6 address after either command, while connect additionally accepts an ordinary host:port pair. llguidance 1.7.6 behaves like this:

ping fe80::1       accepted
connect fe80::1    rejected
connect server:80  accepted

After ping , only HEX can begin. After connect , both HEX and HOST_PORT are active:

fe80     HEX accepting; HOST_PORT alive
fe80:    HOST_PORT alive, non-accepting
fe80::   HOST_PORT dead

HOST_PORT never matches the address. Merely allowing it in this parser context is enough to erase the valid boundary after fe80.

Lark’s contextual lexer accepts connect fe80::1. It applies each eligible regex to the remaining input, so HOST_PORT fails outright while HEX matches fe80. llguidance discovers that failure incrementally while traversing the vocabulary trie. At fe80:, HOST_PORT is still alive; by the time the second colon kills it, the useful boundary has been lost.

So there is no useful rule for terminals in isolation.

A simple lexer rule

There is, however, a simple sufficient condition for the combined lexer:

Once the lexer is accepting, every continuation that keeps it alive must also leave it accepting.

Equivalently, every live successor of an accepting state in the combined lexer must also be accepting.

Our opening lexer fails:

list      accepting
liste     live, non-accepting
listen    accepting

A conventional identifier lexer passes. Once [A-Za-z_][A-Za-z0-9_]* has matched, every further byte that keeps it alive also leaves it matching.

The combined-lexer qualification matters. Suppose the grammar has both:

IDENT       = [A-Za-z_][A-Za-z0-9_]*
KEYWORD_DEF = def

After reading d, the keyword terminal has not matched yet, but the combined lexer is already accepting because d is a valid identifier.

The rule is simple, but stronger than necessary. Ordinary JSON numbers fail it:

1       accepting
1e      live, non-accepting
1e2     accepting

JSON still works because parser context rules out the dangerous handoff.

The one-byte handoff rule

If you really want a rule that admits cases like JSON numbers, you have to include the parser context:

At a valid lexeme boundary, no byte may both continue the current lexeme and begin a legal next lexeme.

For a terminal TT in some reachable parser context, a rough version is:

continue(T)FIRST(after T)=.\operatorname{continue}(T) \cap \operatorname{FIRST}(\text{after } T) = \varnothing.

Here, continue(T)\operatorname{continue}(T) contains bytes that can keep the lexer alive after TT has already matched. FIRST(after T)\operatorname{FIRST}(\text{after } T) contains bytes that can begin a legal following lexeme in the current parser context.

Our weird grammar fails immediately.

After STEM has matched list, the byte e can keep STEM alive:

list + e = liste

The same byte can begin SUFFIX:

ed

The boundary is invisible when the first byte of SUFFIX arrives. It only becomes clear after the later d, by which time the earlier accepting position has been lost.

An identifier followed by ( passes:

IDENT = [A-Za-z_][A-Za-z0-9_]*

The opening parenthesis cannot continue IDENT. It kills the identifier immediately and can be handed to the next parser row.

This rule is conservative. Shared bytes do not prove that llguidance will reject anything. Parser state, terminal priority, the combined lexer, and other implementation details can settle some cases. The overlap shows where the cheap one-byte handoff has stopped being an adequate explanation.

Not a fundamental tradeoff

An obvious explanation is that this lexer behaviour is simply the tradeoff behind llguidance’s speed. It starts almost instantly, generates masks extremely quickly, and avoids building an LR table or a large recognizer up front. The lexer weirdness looks like the price paid for that performance.

It is not free to fix, but it is not required by llguidance’s architecture either. I implemented an opt-in version that remembers earlier accepting positions in the existing lexer stack. If the attempted longer match dies, it returns to the latest one and replays the bytes that came after it. If that gap becomes long, it keeps a synchronized fallback parser snapshot instead.

That adds state, rollback machinery, and some conservative slow paths while a fallback is pending. What it does not require is replacing the lazy lexer, the Earley parser, or the vocabulary-trie walk. The original path remains intact when the feature is disabled.

How llguidance really works

It took me a while to understand what is going on here. Before getting to the vocabulary trie, it helps to isolate one lexing attempt and put it back in its parser-level context.

def recognize(text, parser):
    position = 0

    while position < len(text):
        state = lexer_for(parser)
        match = next_match(text[position:], state)

        if match is None:
            return False

        parser = parser.consume(match.state.matches())
        position += match.length

    return parser.is_accepting()

The parser repeatedly asks the lexer for one match, consumes the resulting terminal, and continues from the end of that match:

find one lexeme
→ give it to the parser
→ continue

The disagreement is entirely inside next_match.

Consider the scan of listed:

Input prefix    Lexer state
------------    -------------------
l               live, not accepting
li              live, not accepting
lis             live, not accepting
list            live, accepting
liste           live, not accepting
listed          dead

Both approaches visit the same live prefixes. They differ only in which prefix they retain as the match.

We can separate that shared walk from the decision:

@dataclass(frozen=True)
class Prefix:
    state: LexerState
    length: int

    @property
    def accepting(self):
        return self.state.is_accepting()
def live_prefixes(text, state):
    for length, char in enumerate(text, start=1):
        next_state = state.step(char)

        if next_state.is_dead():
            break

        state = next_state
        yield Prefix(state, length)

live_prefixes yields every prefix for which the lexer remains live. It stops before the first character that would kill the lexer. For listed, it yields:

l → li → lis → list → liste

Only list is accepting.

Normal maximal munch keeps the last accepting prefix
def next_match(text, state):
    candidate = None

    for prefix in live_prefixes(text, state):
        if prefix.accepting:
            candidate = prefix

    return candidate
llguidance-style tests the last live prefix
def next_match(text, state):
    candidate = None

    for prefix in live_prefixes(text, state):
        candidate = prefix

    if candidate is not None and candidate.accepting:
        return candidate

    return None

After scanning listed, the decisive state is already visible:

normal:       prefix = liste    candidate = list
llguidance:   prefix = liste    candidate = liste

Normal maximal munch remembers list, so the parser can consume STEM and continue with ed. The llguidance-style version retains only liste. Because liste is not accepting, it returns None.

The entire difference is:

normal:
    keep the last accepting prefix

llguidance-style:
    keep the last live prefix, then test it

Put another way, the two operations occur in a different order:

normal      = last(accepting(prefixes))
llguidance  = accepting_or_none(last(prefixes))

This is conceptual pseudocode isolating the greedy-boundary decision, not literal llguidance source. The real implementation also handles multiple active terminals, priorities, lazy lexemes, skipped terminals, parser transitions, and traversal of the tokenizer trie. None of those details changes the distinction shown here.

The normalized live_prefixes version makes the difference easy to see, but neither side would usually be written that way. In their more recognizable forms, the same two policies look like this:

Ordinary lexer scans a complete suffix
def next_match(text, lexer):
    candidate = None

    for length, byte in enumerate(text, start=1):
        lexer = lexer.step(byte)

        if lexer.is_dead():
            break

        if lexer.is_accepting():
            candidate = Prefix(lexer, length)

    return candidate
llguidance recognizer accepts one byte at a time
class Recognizer:
    def advance(self, byte):
        next_lexer = self.lexer_state.step(byte)

        if next_lexer.is_live():
            return Recognizer(self.parser_state, next_lexer)

        if not self.lexer_state.is_accepting():
            return None

        next_parser = self.parser_state.consume(self.lexer_state.matches())
        next_lexer = lexer_for(next_parser).step(byte)

        if next_lexer.is_dead():
            return None

        return Recognizer(next_parser, next_lexer)

The left side is the usual maximal-munch loop: keep scanning, and remember each accepting position in case a longer attempt later fails.

The right side has a different shape because llguidance is an incremental recognizer. It receives one byte at a time. While that byte can continue the current lexer state, it simply advances. Only when continuation fails does it ask whether the current state can be emitted, give that match to the parser, and reuse the killing byte as the beginning of the next lexeme.

That is the same policy as the simpler live_prefixes version. On listed, the ordinary lexer saved list before reaching liste. The llguidance recognizer reaches liste, then sees that d kills it. At that moment its current state is not accepting, so there is nothing to hand to the parser.

In constrained decoding, there is no complete next string. llguidance walks the vocabulary trie, carrying this recognizer state along each possible token prefix. Tokens with the same prefix share the same work:

This is my understanding of the relevant algorithm, not llguidance’s literal implementation. The real code mutates a speculative state stack and pops bytes while walking a flattened vocabulary trie. I return a new recognizer state for each byte because it is easier to follow.

def walk(vocab_node, recognizer):
    if vocab_node.token_id is not None:
        mask.add(vocab_node.token_id)

    for byte, child in vocab_node.children.items():
        next_recognizer = recognizer.advance(byte)

        if next_recognizer is not None:
            walk(child, next_recognizer)

The advance method is the llguidance-shaped function shown above. The parser state determines which terminals are active; the lexer state tracks how far the current terminal has progressed. The vocabulary walk supplies the bytes.

A full vocabulary walk like this takes about a millisecond in the cases I have been testing. llguidance usually does less. Its most important shortcut is subsumption: prove that the current lexer state accepts an entire precomputed slice of the vocabulary, then add that slice to the mask without walking every token in it.

How I changed it

I eventually implemented an opt-in greedy fallback in redesign/greedy-lexeme-fallback-integrated-v3. It lets llguidance continue exactly as before, but remembers where the current greedy lexeme could have ended.

class GreedyReplay:
    accepting_stack_indices = []
    shadow = None

Every byte llguidance pushes already leaves a state in its lexer stack. The new state records which committed stack positions were accepting. During a speculative vocabulary-trie walk, it only scans the part of the lexer stack added by that trie branch. It does not emit the lexeme. It does not fork the parser. The longer interpretation remains the only active interpretation.

For listed, the primary path still looks like this:

list      accepting; remember this position
liste     live, non-accepting
listed    dead

The difference comes when d kills the lexer. Instead of giving up because liste is not accepting, the parser can return to the saved position after list.

Conceptually, the recovery is:

def recover(byte):
    checkpoint = latest_accepting_position()

    restore_lexer_to(checkpoint)
    emit_lexeme_at(checkpoint)
    replay(bytes_after(checkpoint))
    return advance(byte)

The real code is more careful, but that is the operation. It truncates the lexer history to the checkpoint, gives the saved STEM = list match to the Earley parser, then replays the bytes after it—e, followed by the d that exposed the failed longer match. Those bytes are now interpreted in the parser context after STEM, where they form SUFFIX = ed.

So this is not a second recognizer being carried through every byte. Most of the time there is still one recognizer. The fallback interpretation is reconstructed only when the primary interpretation fails.

That matters during the vocabulary walk. llguidance speculatively pushes bytes while descending the token trie, then pops them on the way back out. A recovered fallback must obey the same protocol. The implementation saves an undo snapshot before speculative recovery, so a later pop_bytes() can restore the exact primary state. Definitive token consumption has a corresponding undo record so token rollback can cross a fallback promotion and later take the long match instead.

Replay is not enough

A checkpoint can remain unresolved for an arbitrarily long time. Consider a terminal matching either a or ab{10000}c. If the input is really a followed by another lexeme beginning with ten thousand bs, waiting for the final failure and then replaying all ten thousand bytes would produce a fairly spectacular latency spike.

The implementation therefore changes strategy after 64 bytes. It materializes the fallback interpretation as a complete parser snapshot—a shadow—and keeps it synchronized as tokens or forced bytes are committed.

short gap:
    remember checkpoint
    replay only if the primary path fails

long gap:
    materialize fallback parser snapshot
    advance it with committed input
    promote it if the primary path fails

The shadow is not another member of a general recognizer bundle. There is at most one synchronized fallback snapshot for the current greedy checkpoint. During a speculative trie walk, if the primary path fails, llguidance swaps to that snapshot and replays only the bytes added by the current trie branch. Deferred replay is therefore bounded by the 64-byte threshold plus the current token-trie path, rather than by the total length of the unresolved lexeme.

The newest accepting position is tried first. If the longer candidate reaches another accepting state, that becomes the active checkpoint and any materialized shadow for the older one is discarded. Older checkpoints can still reappear during nested recovery, but they do not compete with a later successful greedy match. This preserves llguidance’s combined-lexer maximal-munch semantics. It does not implement per-terminal maximal munch, and it does not preserve every possible lexical segmentation.

That distinction is tested directly. With:

start: A B | AB C
A: "a"
B: "b"
AB: "ab"
C: "c"

abc is accepted, but ab is still rejected at end of input: once AB = ab has arrived, the earlier A = a boundary has lost. The fallback only rescues an earlier accepting boundary when the attempted longer match fails before producing a later greedy match.

A pending checkpoint also means lexer history now matters. Two recognizers can have the same current DFA state but different saved boundaries. The branch therefore avoids mask-cache reuse keyed only by the current lexer state, declines the quick forced-byte answer, and disables tokenizer-slice subsumption while a fallback is pending. These are conservative choices; they keep the ordinary machinery untouched when no checkpoint is alive.

The feature is opt-in:

%llguidance {"greedy_lexeme_fallback": true}

With it disabled, llguidance uses its original state and hot path. With it enabled, the branch passes focused tests for listed, the contextual IPv6 example, f-string chunks, end of input, long and nested checkpoint chains, captures, cloning, validation, forcing, stop and suffix lexemes, token rollback, and repeated mask generation.

So the eventual answer was not to keep both recognizers running and kill one later. It was to keep the greedy recognizer running, remember the last place it could have stopped, and make returning there cheap enough to work inside llguidance’s speculative token-trie traversal.

The graveyard

Nice terminal. Shame about its neighbour.

Whether a greedy terminal is safe depends on what the parser permits after it.

Consider a dotted stem:

STEM = [a-z]+(\.[a-z]+)*

It behaves comfortably before a colon:

QUALIFIED_VALUE ::= STEM COLON VALUE

COLON = :

After api.v1, the colon kills STEM immediately.

Now reuse the same terminal in a filename grammar:

FILE ::= STEM EXT

STEM = [a-z]+(\.[a-z]+)*
EXT  = .json | .txt

The first byte of EXT is .. A dot can also continue STEM.

Nothing about the regex for STEM changed. Its parser context did. A terminal that had obvious boundaries in the colon-delimited grammar now sits beside another terminal beginning with one of its continuation bytes.

Ordinary longest-match lexers can also behave badly on grammars like this. They may swallow report.json as one large STEM and leave nothing for EXT. Hidden lexical boundaries have been upsetting parser authors since long before LLMs arrived.

A better grammar can expose the decision to the parser:

FILE ::= NAME (DOT NAME)* DOT EXT_NAME

NAME     = [a-z]+
DOT      = .
EXT_NAME = json | txt

Now NAME stops before the dot. The parser decides which dot begins the extension.

This does not mean every character should become a separate terminal. llguidance has good reason to warn against repetitions of one-character lexemes: they wake the parser for every character. Split terminals where a real boundary choice belongs in the grammar.

Some nearby grammars

I tested several variations to make sure I was not looking at a general failure to process model tokens containing several lexemes:

GrammarCandidate bytesByte-language resultObserved result
`(“list""listen”) “ed”`listedvalid
"list" "ed"listedvalidaccepted
`(“list""listen”) “X”`listXvalid
`(“list""listen”) “X”`listeXinvalid

The second case removes the accepting-to-non-accepting transition. "list" is dead when the following e arrives, so llguidance can emit list and hand that e to "ed".

The third case keeps the longer listen alternative but changes the suffix to "X". The X immediately kills STEM while the previous state, list, is accepting.

Only the first grammar requires an older accepting position to survive while the lexer explores a longer possible match.

Fixing an affected grammar

When a grammar contains adjacent greedy terminals, I would check three things.

First, find bytes that can keep the old lexeme alive after it has matched. For "list" | "listen", e is such a byte.

Then find bytes that can begin the following lexeme in each reachable parser context. SUFFIX = ed also begins with e.

Any overlap deserves a small test. Give the tokenizer a token that crosses the suspected boundary. The most useful test token is usually the shortest witness containing an accepted prefix, a byte that leaves the lexer alive but non-accepting, and enough later bytes to expose the desired split.

For this grammar:

accepted prefix:      list
ambiguous next byte:  e
following byte:       d
candidate:            listed

If llguidance rejects it, the grammar can often be repaired with a delimiter or by moving the boundary choice into the parser.

A delimiter is the easy version:

START ::= STEM COLON SUFFIX

STEM   = list | listen
COLON  = :
SUFFIX = ed

The colon immediately kills STEM.

Moving the lexical choice into the parser is useful when punctuation is unavailable:

START  ::= STEM SUFFIX
STEM   ::= LIST | LISTEN

LIST    = list
LISTEN  = listen
SUFFIX  = ed

Earley can retain both stem alternatives, so the segmentation choice is visible to the parser instead of hidden inside one greedy terminal.

What the repair costs

The six-letter grammar isolates the mechanism, but it does not show why anyone should care about the implementation choice. I tested two less artificial cases against the Llama 3 128K vocabulary: an ordinary Python f-string and a tiny C++ template call.

These are recognition benchmarks, not claims about preserving a particular parse tree. In both cases, the natural grammar works in GLRMask and fails in llguidance. Moving the hidden lexical choice into the parser makes llguidance recognise the same byte string, but increases the amount of parser work performed while constructing token masks.

A Python f-string

Consider a plausible generated line:

message = f"User {user.name} completed {task.name} in {duration} seconds with status {status}."

A natural grammar keeps literal f-string text in one regex terminal:

statement: "message = f\"" FCHUNK? ("{" expr "}" FCHUNK?)* "\"\n"
expr: NAME ("." NAME)*

FCHUNK: /(?:[^{}"\\]|\\.|\{\{|\}\})+/
NAME: /[A-Za-z_][A-Za-z0-9_]*/

The literal text before an interpolation may end at a single {. But FCHUNK also permits {{ as an escaped literal brace. After reading User , the terminal is accepting. The first { keeps it alive but makes it non-accepting; only the later u in user proves that the { should have begun an interpolation. llguidance rejects the line because recovering it requires the older boundary before {.

The recognition-preserving repair makes the alternatives parser-visible:

statement: "message = f\"" piece* "\"\n"

?piece: TEXT | ESCAPE | "{{" | "}}" | "{" expr "}"
expr: NAME ("." NAME)*

TEXT: /[^{}"\\]+/
ESCAPE: /\\./
NAME: /[A-Za-z_][A-Za-z0-9_]*/

On my M1 Pro MacBook Pro, over 1,001 complete runs using thread CPU time:

Engine and grammarMedian maximum maskp99 maximum maskObserved maximumMedian total mask time
llguidance, repaired1.314 ms1.422 ms1.598 ms15.457 ms
GLRMask, natural11.625 µs20.417 µs80.625 µs284.542 µs

Nine token positions in that one line had a median llguidance mask time above 0.5 ms. The median-maximum ratio was about 113×, and the total-mask-time ratio was about 54×. GLRMask compiled this small natural grammar in 74.4 ms in the measured run.

This is not a complete Python grammar. It is a small recognition-only subset of a routine construct an LLM may genuinely generate. That is enough to expose the trade-off without hiding it inside a pathological regex.

A tiny C++ template call

The same problem appears in a much shorter C++ line:

f<A<int>>();

Including the following newline, Llama 3 tokenises the end as one token:

>>();\n

In the natural grammar, >> is an indivisible right-shift terminal. llguidance greedily takes the beginning of that token as a shift. The expression interpretation only dies later, after the call and semicolon syntax arrives. By then it cannot retreat to the two parser-visible > boundaries required by the nested template call.

The repair removes RSHIFT: ">>" where the ambiguity matters and represents the same syntax with parser-level pieces:

?relational: shift (("<" | ">" | "<=" | ">" "=") shift)*
?shift: additive ((LSHIFT | ">" ">") additive)*
template_suffix: "<" template_argument_list ">"

The natural llguidance grammar rejected the line. The repaired grammar accepted it at default limits. In repeated complete runs, its slowest median mask was about 2.4 ms, and independent observed maxima were approximately 4.5–5.9 ms. In a final 1,001-run GLRMask measurement on the same token sequence, the median maximum was 36.8 µs for the mask, 25.8 µs for commit, and 62.7 µs for mask plus commit. The p99 maximum mask-plus-commit time was 139.8 µs.

The useful part is that this tiny example scales without changing the mechanism. Add more nested A templates:

f<A<A<A<A<A<A<A<int>>>>>>>>();

The eight closing brackets are one Llama 3 token:

>>>>>>>>

The repaired llguidance grammar now exceeded its default 50,000-item mask limit. With the limit raised, three independent 1,001-run processes measured:

MeasurementRange across processes
Median maximum mask5.510–5.669 ms
p99 maximum mask5.939–8.672 ms
Observed maximum7.475–11.089 ms
Median total mask time36.387–38.187 ms

The slowest position was the >>>>>>>> token. This grammar is extraordinarily ambiguous: immediately before that token, the GLR parser represents 6,765 concrete stacks at depth seven and 17,711 at depth eight, despite retaining only four GSS roots. The path count grows in a Fibonacci-like sequence because each nested template argument can remain both a type and an expression.

It exposed three general GLRMask runtime problems.

First, the mask queue preserved depth-first traversal but left work for the same parser-DWA target and stack depth separate. At depth eight, that expanded a compact GSS into roughly 15,700 queue items. The queue now unions same-target work immediately within each depth bucket.

Second, several production commit paths handled tables requiring exact admission by simulating the full GLR reduction closure to ask whether a terminal could advance, then performing essentially the same parser advance again. Exact admission is now answered by the actual advance result, while tables with exact row-presence admission retain their cheap precheck.

Third, every nondeterministic reduction wave speculatively tried six old whole-GSS specialisations before entering the generic algorithm. Those routines had been valuable for earlier parser implementations, but later generic GSS improvements had overtaken them. Even the Snowplow replay that motivated one of the paths showed no median benefit and worse tail latency. The routines remain available for diagnosis, but are no longer enabled by default.

With those runtime and compiler fixes, the final 1,001-run measurements were:

Input depthMedian maximum maskMedian maximum commitMedian maximum mask + commitp99 maximum mask + commit
136.7 µs24.5 µs61.4 µs92.5 µs
7274.7 µs291.5 µs567.7 µs735.1 µs
8385.2 µs355.2 µs744.5 µs941.0 µs

The depth-seven GLRMask mask-plus-commit time is therefore about ten times below repaired llguidance’s mask time alone. At depth eight, GLRMask remains below 0.75 ms at the median maximum despite representing 17,711 concrete stacks.

These changes were not accepted on the strength of this one grammar. The runtime candidate completed the full 226-problem GLRMask slow suite; across 1,053 timed examples, the largest displayed maximum was 20 µs for masking, 22 µs for commit, and 32 µs for mask plus commit. The later compiler candidate also completed a 226-problem safety sweep with no status, build-availability, or semantic differences from production. The complete Rust workspace passed, every benchmark target built, and generated differential tests compared both the parser-table transformation and the grouped cancellation fixed point against their original implementations.

Compilation remains a deployment consideration, but the original pathology is substantially reduced. The natural grammar previously took about 3.74 seconds to compile. It now takes about 1.29 seconds, while median maximum mask-plus-commit at depth eight falls from 0.806 ms to about 0.745 ms. The artefact grows from 6.63 MB to 6.72 MB, an increase of about 1.4%. An earlier fresh-load measurement of the smaller artefact was 31.75 ms at the median, 58.70 ms at p99, and 71.67 ms at maximum; I did not rerun that load benchmark after the compiler change.

So this is not evidence that GLRMask wins every axis. It is evidence that preserving lexical alternatives can keep the online path substantially cheaper, provided the implementation continues to operate on the compact weighted GSS rather than re-expanding or redundantly simulating it. The tiny call is the clean correctness witness; increasing its nesting became a useful regression test for ambiguity handling in both the runtime and compiler.

The benchmark used llguidance 1.7.6 at commit e98236e877125522028223ad5a86caa752874fb6, GLRMask at commit 4ab0ce5bf17a935ddab87617fce3898d7133d73d, the same Llama 3 vocabulary in both systems, and no_forcing for llguidance. The figures are local measurements, not universal constants.

Bug, semantics, or both?

Under an existential interpretation of adjacent terminals, listed is valid because there exists a split into list and ed. A longest-match scanner with last-accept rollback can find that split.

llguidance exposes lexeme greediness, laziness, forcing, and other lexical behaviour as part of its grammar semantics. It may choose a more restricted online handoff. The three public operations I tested agreed with each other, so this was not a case where the mask rejected a token that consumption would accept.

It remains surprising. Someone reading the grammar can reasonably expect the earlier accepting boundary to survive. The smallest useful counterexample is an ordinary six-letter word, and the distinction becomes relevant precisely when a custom grammar wanders beyond the delimiter-heavy world of JSON.

So no, llguidance does not literally hate weird grammars.

It would merely prefer that they announce every lexeme boundary within one byte.

Sources

The lexer-path source links are pinned to llguidance v1.6.1, commit d9da1ae3b77dcea7e82b99384fd235bd4409dd77. The practical benchmarks above were repeated against llguidance 1.7.6 at commit e98236e877125522028223ad5a86caa752874fb6.

Greedy lexers need a memory

Suppose a lexer is reading STEM = "list" | "listen".

After four bytes, it has a match:

list

It cannot emit STEM yet. The next bytes might be en, making the longer greedy match listen.

The next byte is e:

liste

This does not match either stem, but it might grow into the longer one. One more byte would give us:

listen

The lexer has moved from an accepting state into a live, non-accepting state:

Bytes tentatively read as STEMLexer state
listaccepting
listelive, non-accepting
listenaccepting

But in listed, the next byte is d:

listed

d kills the longer attempt. A conventional longest-match scanner returns to the last accepting position, which was after list. It emits STEM = list, then reads the remaining ed as SUFFIX.

This requires the lexer to remember an accepting position from two bytes ago. Remembering only the immediately previous state is insufficient. Immediately before d, the lexer was at liste, which had not matched anything.