cld-toys › Toys › regex-engine

Commentary: regex-engine

One NFA, two ways to walk it. The same 61 states and the same answer cost 13,631,486 steps one way and 841 the other — and the exponent turns out to live in two adjacent lines you could swap. A study guide for regex.py.

regex-engine/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with regex.py open beside you. regex.py is the toy itself (287 lines, 146 of them executable); demo.py prices the same match under both engines; test_regex.py is 15 tests, including a brute-force agreement check over 152,908 (pattern, text) pairs. Every transcript was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd regex-engine
python3 demo.py           # the aha (§6) -- about 13s, most of it spent backtracking
python3 demo.py --timing  # adds wall-clock seconds (machine-dependent)
python3 test_regex.py     # 15 tests, ~22s; pins every number this page quotes
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy compiles a regular expression into an NFA — a little graph — and then matches by walking that graph. It walks it twice, two different ways, and counts every step.

There is only one graph. That is the thing to hold on to. The two matchers in regex.py are handed the identical object returned by compile_nfa. They are not two algorithms with different data structures; they are two traversal orders over the same 61 states. One of them takes 13,631,486 steps on a 20-character input. The other takes 841. They return the same answer.

By the end you should be able to:

The step unit, up front, because the whole page rests on it One step = one arrival at a (state, position) pair. Both engines tick that same counter on that same graph. Neither is measured in a unit the other does not pay. §6.2 shows what happens if you charge the simulator more harshly, and §5.6 shows the one line that would let it charge itself less.

2. The problem this mechanism exists to solve

A regular expression is a specification, not a program. a?a?aa says "optionally an a, optionally an a, then two as" — it does not say what order to try things in. Something has to turn that specification into a sequence of decisions, and the honest difficulty is that the specification is ambiguous about work: for the input aa there are several ways to line the input up against the pattern, all of which are "the pattern matched."

An engine has to pick a strategy for exploring those ways, and the competing goals are real:

This toy strips the problem to the point where the strategy is the only variable left. No captures, no ranking, no optimisation passes — just one graph and two ways to walk it, both counting.

The intuition this toy is built to break "Backtracking is the slow one." Almost, but the emphasis is wrong, and §6.3 puts a number on how wrong. Swap two adjacent stack.append lines in the backtracker — changing which arrow of a branch it tries first, and nothing else — and the pathological match drops from 13,631,486 steps to 41. It is the same backtracking algorithm, still backtracking, still returning the same answer. The exponent was never in the backtracking. It was in the order.

3. Background you need

ConceptWhere it's used in the toyOne link
NFA — a state machine that may be in many states at oncethe entire output of compile_nfa; match_thompson takes "many states at once" literally and stores a listWikipedia: Nondeterministic finite automaton
Thompson's constructioncompile_nfa, lines 136–178 — one Fragment per operator, each with one entry and many dangling exitsWikipedia: Thompson's construction
Epsilon transitionnot a state kind here — a SPLIT is the epsilon. Both arrows of a SPLIT are followed without consuming inputWikipedia: NFA — ε-moves
Epsilon closureadd_state, lines 241–252: the recursive walk that expands a SPLIT into the set of CHAR states actually reachable nowWikipedia: Thompson's construction
Shunting-yardto_postfix, lines 59–76 — turns infix regex into postfix so the compiler is a flat loopWikipedia: Shunting yard algorithm
DFS vs. BFSthe only difference between the two matchers: match_backtrack pops a LIFO stack, match_thompson sweeps a frontierWikipedia: Depth-first search
ReDoSwhat §6.6's GAVE_UP verdict is, in production termsOWASP: Regular expression Denial of Service

The two starred rows carry the result. Thompson's construction is why there is a graph to walk at all, and its one-entry/many-exits invariant is what keeps the compiler to three lines per operator. Epsilon closure is where the entire performance story lives: the closure's seen set is the difference between the two engines, and §6.5 shows that deleting it turns the simulator into the backtracker to the digit.


4. The mental model

A regex compiles to a graph with exactly three kinds of node:

CHAR 'a' SPLIT MATCH +-------+ +-------+ +-------+ -->| 'a' |--> | * |--> out | ACCEPT| +-------+ | \ | +-------+ consumes one | \--> out1 character +-------+ consumes nothing, takes BOTH arrows

a? becomes a SPLIT whose out enters the body and whose out1 skips it:

+-----------------+ | v --> SPLIT --> CHAR 'a' --> (rest of the pattern) | ^ +---- out1 -----------+ (skip)

Chain three of those in front of aaa and you have pathological(3). Now the two ways to walk it. Take a?a?aa against "aa":

BACKTRACKING (depth first, one path at a time) try take,take -> "aa" consumed by the a? pair, then needs "aa" more -> FAIL try take,skip -> "a" consumed, needs "aa" more, only "a" left -> FAIL try skip,take -> "a" consumed, needs "aa" more, only "a" left -> FAIL try skip,skip -> nothing consumed, needs "aa", has "aa" -> MATCH ^^^^ 4 = 2^2 paths. At n=20 that is 1,048,576. THOMPSON (breadth first, all paths at once) pos 0: live = {every state reachable now} <- a SET, deduplicated pos 1: live = {every state reachable after 1 'a'} pos 2: live = {...} -> does it contain MATCH? ^^^^ 3 = |text|+1 sweeps. At n=20 that is 21.
The slogan, before you read a line of code The backtracker asks "does THIS path work?", 2n times. The simulator asks "which states are live NOW?", |text|+1 times. A state can be live at a position only once — so the second question has a bounded number of answers, and the first does not.

5. Reading the source

5.1 The step unit, declared in the module docstring

regex.py · lines 10–19
THE STEP UNIT
-------------
One step = one arrival at a (state, position) pair.

Both matchers tick that same counter on that same graph, so their numbers are
directly comparable. `add_state` ticks BEFORE consulting `seen`, so arrivals
the simulator discards are still charged to it: it is not allowed to look
cheap by declining to count. (An arrival is charged once. The simulator is
not separately charged for re-reading a state already in its live list; the
commentary, section 6, prices that alternative too.)

This is in the source rather than only on this page because a benchmark whose unit lives in the prose is a benchmark nobody can check. The headline of this toy is a ratio, and a ratio between two counters is worth exactly as much as the claim that they count the same thing. §6.2 and §6.5 are both attempts to break that claim.

5.2 add_concat — making concatenation visible

regex.py · lines 35–49
def add_concat(pattern):
    """Insert an explicit CONCAT token wherever juxtaposition means "then".

    A token boundary is a concatenation unless the left side is still open
    (`(`, `|`) or the right side is a closer or a postfix operator.
    """
    out = []
    previous = None
    for char in pattern:
        if previous is not None:
            if previous not in "(|" + CONCAT and char not in ")|*+?":
                out.append(CONCAT)
        out.append(char)
        previous = char
    return "".join(out)

Regex has an operator you cannot see. ab means "a then b", but the "then" is written as nothing at all, and a shunting-yard parser cannot shunt an operator that isn't in the input. So the first thing the toy does is insert one — CONCAT = "\x01", a character no realistic pattern contains.

The two membership tests are the whole rule, and they are asymmetric on purpose. The left side must not be open (( or | — nothing has been produced yet to concatenate to). The right side must not be a closer or a postfix operator (), |, *, +, ? — these attach to what precedes them rather than starting a new term). Everything else is a juxtaposition, and juxtaposition means concatenation.

5.3 compile_nfa — three lines per operator, and the reason it can be three

regex.py · lines 147–159
        elif token == "?":
            fragment = stack.pop()
            split = State(SPLIT)
            split.out = fragment.start
            # `out` points at the body and `out1` (the skip) is left dangling.
            # The backtracker tries `out` first, so THIS is where greed lives.
            stack.append(Fragment(split, fragment.dangling + [(split, "out1")]))
        elif token == "*":
            fragment = stack.pop()
            split = State(SPLIT)
            split.out = fragment.start
            patch(fragment.dangling, split)  # loop the body back to the split
            stack.append(Fragment(split, [(split, "out1")]))

Look at what these lines don't do: they never inspect fragment. ? doesn't care whether its body is one character or a 200-state sub-expression. That is bought by the Fragment invariant — one entry point, any number of dangling exits — and it is the reason Thompson's construction is a page of code instead of a chapter.

The dangling list holds (state, "out1") pairs rather than states, because an unfinished arrow has no target yet; it is a slot. Naming the slot lets patch fill it in later without the fragment remembering which kind of arrow it was.

? and * differ by exactly one line. ? passes the body's exits through (fragment.dangling + ...), so the body runs at most once. * patches them back to the split (patch(fragment.dangling, split)), so the body can run again. And + — three lines further down — is * with fragment.start instead of split as the entry point, which is precisely what "one or more" means: you arrive inside the body before you are ever offered the exit.

Where greed lives Note which arrow gets the body. split.out points at the body; split.out1 is the skip. The backtracker explores out first, so a? means "try to consume it, and only decline if that fails." Greed is not implemented in the matcher. It is a property of which arrow the compiler attached the body to — and §6.3 is what happens when you disagree with it.

5.4 match_backtrack — nine lines, and one of them is the exponent

regex.py · lines 207–224
    steps = 0
    stack = [(start, 0)]
    while stack:
        state, pos = stack.pop()
        steps += 1
        if steps > budget:
            return GAVE_UP, steps
        if state.kind == MATCH:
            if pos == len(text):
                return True, steps
        elif state.kind == SPLIT:
            # LIFO: whichever is pushed second is explored first.
            first, second = (state.out, state.out1) if greedy else (state.out1, state.out)
            stack.append((second, pos))
            stack.append((first, pos))
        elif pos < len(text) and state.accepts(text[pos]):
            stack.append((state.out, pos + 1))
    return False, steps

The stack is explicit rather than recursive, for two reasons. It is more faithful — PCRE and friends keep their own backtrack stack rather than leaning on the C stack — and it makes budget the only thing that can stop this function. With recursion, (a*)*b raises RecursionError at whatever depth Python happens to allow, and the real failure mode (an unbounded search) gets disguised as an implementation detail of the host language.

budget is therefore not a testing convenience bolted on for the demo. It is load-bearing: without it this function does not terminate on a pattern whose NFA contains an epsilon cycle, and GAVE_UP is the honest third verdict. That is exactly the timeout a production engine reports, and §6.6 runs it.

The two stack.append lines are the subject of §6.3. Because a stack is LIFO, the one pushed second is explored first — so pushing second then first means first wins, and first is state.out, the body. greedy is a parameter only so the demo can price the identical match both ways. A real engine has no such freedom: once you report capture groups, greedy order is part of what * is defined to mean, not a tuning knob.

5.5 match_thompson — and the four characters that are the whole algorithm

regex.py · lines 239–264
    steps = 0

    def add_state(state, live, seen):
        """Walk the epsilon closure, appending CHAR/MATCH states to `live`."""
        nonlocal steps
        steps += 1  # charged before the `seen` check, on purpose
        if state.sid in seen:
            return
        seen.add(state.sid)
        if state.kind == SPLIT:
            add_state(state.out, live, seen)
            add_state(state.out1, live, seen)
        else:
            live.append(state)

    live, seen = [], set()
    add_state(start, live, seen)
    for char in text:
        following, seen = [], set()
        for state in live:
            if state.accepts(char):
                add_state(state.out, following, seen)
        live = following
        if not live:
            break  # nothing is live; no later character can revive anything
    return any(state.kind == MATCH for state in live), steps

Compare this to §5.4 and notice how much is the same. Both functions follow both arrows of a SPLIT. Both advance on a matching CHAR. Both start from start at position 0. The differences are two:

  1. live is swept once per input character (breadth) instead of a stack being popped to exhaustion (depth);
  2. seen — a fresh set per position — refuses an arrival at a state already reached at this position.

The second one is the algorithm. Everything else is presentation. §6.5 deletes it and gets the backtracker's step counts back, exactly.

seen is rebuilt per position (following, seen = [], set()) rather than kept for the whole match, and it has to be: a state that was live at position 3 may legitimately be live again at position 7. What is forbidden is being live twice at the same position, because a second arrival at the same (state, position) pair can only lead where the first one already went. That sentence is the entire proof of the linear bound.

5.6 The line that could have cheated

regex.py · line 244
        steps += 1  # charged before the `seen` check, on purpose

One line above the if state.sid in seen: return. Move it one line down and the simulator stops paying for arrivals it rejects — which is arguably more "real," since a rejected arrival does almost no work. It would also take the headline from 841 to 651 (§6.2 runs it), improving a ratio this page is built on by 29%.

It stays where it is. When a toy's entire output is a comparison, the counter that flatters the side you are advocating is the one that has to be argued for, not the one that gets adopted quietly.


6. The demo, and what it proves

python3 demo.py, captured in full. It is byte-identical across runs — diffed two consecutive runs to confirm — because there is no clock, no RNG and no hashing anywhere in the counted path.

one NFA, two walks -- step counts only (no clock, no RNG) 1 step = one arrival at a (state, position) pair, in BOTH engines NFA for 'a?a?aa' (7 states) 0 SPLIT --> 1 (or 2) 1 char 'a' --> 2 2 SPLIT --> 3 (or 4) 3 char 'a' --> 4 4 char 'a' --> 5 5 char 'a' --> 6 6 MATCH ...that is pathological(2). The demo below grows n. pathological: ('a?' * n) + ('a' * n) against 'a' * n (it MATCHES) n states |text| backtrack thompson ratio agree 1 4 1 5 5 1.0x True 2 7 2 14 13 1.1x True 3 10 3 34 25 1.4x True 4 13 4 78 41 1.9x True 5 16 5 174 61 2.9x True 8 25 8 1,790 145 12.3x True 10 31 10 8,190 221 37.1x True 12 37 12 36,862 313 117.8x True 15 46 15 344,062 481 715.3x True 18 55 18 3,145,726 685 4,592.3x True 20 61 20 13,631,486 841 16,208.7x True 22 67 22 58,720,254 1,013 57,966.7x True

6.1 The headline, derived

Neither column is quoted here without arithmetic behind it. Both curves have closed forms, and test_the_closed_forms_reproduce_every_measured_row asserts them against every row above:

Backtracking = (n + 6) · 2n−1 − 2 At n = 20: (20 + 6) · 219 − 2 = 26 · 524,288 − 2 = 13,631,488 − 2 = 13,631,486.

The shape is the point: 2n−1 is the number of ways to distribute the input over the n optional a?s, and the linear factor is how deep each of those attempts runs before it fails. Exactly one of the 1,048,576 assignments works — the one where every a? declines — and greedy order proposes it last.

Thompson = 2n(n + 1) + 1 At n = 20: 2 · 20 · 21 + 1 = 840 + 1 = 841.

Where the 841 comes from, counted per position:

=== CF5: where Thompson's 841 comes from (arrivals per position, n=20) === position: 0(closure) then 1..20 arrivals: [41, 59, 57, 55, 53, 51, 49, 47, 45, 43, 41, 39, 37, 35, 33, 31, 29, 27, 25, 23, 21] = 41 + sum(61 - 2k for k in 1..20) = 41 + 800 = 841 bound check: 61 states x 21 positions = 1281, and 841 < 1281

The initial epsilon closure charges 41 arrivals. Then each of the 20 characters charges (61 − 2k), shrinking by 2 per position as the a? chain is consumed and can no longer be re-entered. The sum is 20 · 61 − 2 · (1+2+…+20) = 1,220 − 420 = 800, plus the opening 41 = 841.

And the guarantee, visible as a number: 61 states × 21 positions = 1,281 is the ceiling the seen set imposes, and 841 sits under it. The backtracker respects no such ceiling; 13,631,486 is over ten thousand times the total number of (state, position) pairs that exist — a factor of 10,641, which it can only achieve by arriving at the same pair over and over.

The ratio: 13,631,486 ÷ 841 = 16,208.7×, on the same graph, for the same verdict.

6.2 The accounting disclosure

The headline is a ratio between two counters, so the counters deserve suspicion. Two ways to attack them, both run.

Charge the simulator more. The shipped meter charges one step per arrival but does not separately charge for re-reading a state already sitting in the live list during the character sweep. Charge that too:

disclosure -- the simulator's meter, charged more harshly: also charging for re-reading a live state: 1,261 steps the ratio becomes 10,810x instead of 16,209x

841 becomes 1,261 and the ratio falls from 16,209× to 10,810×. Four orders of magnitude either way; the accounting choice is not what produces the result.

Charge the simulator less. Moving steps += 1 below the seen check (§5.6) goes the other way:

=== CF2: move `steps += 1` below the `seen` check === (a|a)b as shipped= 6 tick-after-seen= 5 pathological(20) as shipped= 841 tick-after-seen= 651 Charging only for arrivals that SURVIVE takes the headline from 841 to 651, a 190-step discount. The shipped counter is the less flattering one.

The toy ships the meter that makes its own headline smaller. Between 651 and 1,261 there is roughly a factor of two, and the result is the same at either end.

6.3 What the exponent is actually made of

This is the most surprising thing in the toy, and it is one line.

WHAT THE EXPONENT IS ACTUALLY MADE OF -- swap two lines in the backtracker so a SPLIT tries `out1` before `out`, and nothing else: n greedy (as shipped) skip-first change 5 174 11 15x 10 8,190 21 390x 15 344,062 31 11,098x 20 13,631,486 41 332,475x Same engine, same graph, same verdict. The blow-up is not caused by backtracking -- it is caused by backtracking in the WRONG ORDER.

At n = 20: 13,631,486 → 41, a factor of 332,475, from swapping two adjacent stack.append calls. The engine is still a backtracker. It still walks depth-first, still commits to a path, still reconsiders. It is still righttest_branch_order_never_changes_an_answer checks all 762 (pattern, text) pairs over six patterns and finds no verdict changed.

The reason is visible in the arithmetic of §6.1: exactly one of the 220 assignments matches, the one where every a? declines, and greedy order puts it last in the enumeration. Skip-first order puts it first, so the walk goes straight down the correct path — 41 steps, which is 2 per character plus change.

So why not just ship skip-first? Because it is a different guess, not a better algorithm — and it is not even available to a real engine:

...but reordering is a different guess, not a cure. On input that does NOT match, every branch must be refuted whichever way you go: n=20, text='a'*19+'b' greedy= 12,582,910 skip-first= 12,582,910

Identical to the digit. When there is no match, order cannot help, because every path has to be refuted before you can say "no". A reordering only ever moves which inputs are cheap; it never removes the exponent. And in a real engine the order isn't yours to choose at all: once * has to report what it captured, greedy is part of the definition, which is the point Cox makes about split instruction priority in the virtual-machine article in §10.

6.4 Boundary — the backtracker wins on ordinary patterns

The place where the whole effect vanishes, and then reverses:

BOUNDARY 1 -- on ordinary patterns the BACKTRACKER wins: pattern |text| backtrack thompson bt/th a*b 201 404 604 0.67x .*b 201 407 607 0.67x (a|b)*abb 203 723 1,223 0.59x aaaaaaaa..(40) 40 41 41 1.00x ...the set costs more to carry than it saves, when only one path is live.

a*b on 200 as and a b: 404 backtracking steps against 604 simulator steps. The simulator loses by 1.5×, and it loses for a good reason — it is paying to carry a set of live states through 201 positions when only one path was ever live. The backtracker's optimism is correct here: it guesses the greedy path, the greedy path is right, and it never reconsiders.

The last row is the degenerate case. aaaa… (40 literal as) compiles to a graph containing no SPLIT at all — only 40 CHAR states and a MATCH — so there is nothing to be clever about and both engines spend exactly 41 steps.

The sentence to keep Thompson simulation is insurance, not speed. You buy a bound on the worst case and you pay a constant factor of roughly 1.5× on the ordinary case. Whether that is a good trade is entirely a question of who writes your patterns.

6.5 Boundary — the pattern is not what is pathological

Two more ways the effect disappears, both about the input rather than the pattern.

BOUNDARY 2 -- the pathological pattern needs pathological INPUT: text='a'*20 match backtrack= 13,631,486 thompson= 841 text='b' + 'a'*19 no match backtrack= 41 thompson= 41 text='a'*19 + 'b' no match backtrack= 12,582,910 thompson= 820 a leading 'b' kills every branch at position 0: 41 steps, not 13 million.

The same 61-state NFA that costs 13.6 million steps costs 41 on "baaaa…". One wrong character at position 0 refutes every branch before any branching has happened, so there is no tree to explore. Move that same wrong character to the end and the cost comes straight back (12,582,910) — the whole tree must be explored to reach the failure.

That is why "is this regex dangerous?" is not a question about the regex. a?nan is perfectly cheap on almost all inputs. It is expensive on the inputs an attacker picks.

And here is the direct proof that the two engines really are one algorithm plus a set:

=== CF1: delete the `seen` set from match_thompson === n with seen without seen match_backtrack 5 61 174 174 equal=True 10 221 8,190 8,190 equal=True 15 481 344,062 344,062 equal=True 20 841 13,631,486 13,631,486 equal=True The de-duplicated simulator IS the backtracker, to the digit.

Delete four characters' worth of bookkeeping from add_state and the simulator produces the backtracker's step counts exactly — 13,631,486 at n = 20, not approximately, not in the same ballpark, identically. Whatever else is different between §5.4 and §5.5, it is not where the performance lives. The seen set is the algorithm.

6.6 The other failure mode: a cycle that consumes nothing

THE OTHER FAILURE MODE -- an epsilon cycle, `(a*)*b`: text='b' backtrack=GAVE UP (100,001 steps) thompson=match ( 6 steps) text='aaab' backtrack=GAVE UP (100,001 steps) thompson=match ( 21 steps) text='aaac' backtrack=GAVE UP (100,001 steps) thompson=no match ( 20 steps)

(a*)* puts a star around something that can match the empty string. The inner a* can complete without consuming input, the outer * loops back, and the NFA has a cycle of SPLITs with no CHAR on it. Depth-first search enters that cycle and never leaves — not "slowly", never. The budget is the only reason the first column terminates at all.

Look at the first row: the answer is True and it is trivially so, the input is a single b. The backtracker cannot report it, because it descends into the epsilon cycle before it ever considers the branch that would succeed. This is not an exponent; it is non-termination, and no budget size fixes it — test_the_budget_is_the_only_thing_that_stops_the_backtracker runs budgets of 10, 1,000 and 250,000 and gets GAVE_UP at exactly budget + 1 every time.

The simulator answers in 6 steps, and agrees with CPython's re. The seen set closes the cycle after one pass — the second arrival at a state it has already reached at this position is exactly the arrival that would have looped, and it is refused.

6.7 Wall clock, under a banner

Step counts are properties of an algorithm. Seconds are properties of a machine, so demo.py keeps them behind --timing and this page reports them only with the hardware named: macOS 26.5.2, Apple M1 Max (arm64), Python 3.15.0a8.

--- WALL CLOCK (machine-dependent; declare your hardware) --- python 3.15.0a8 darwin n=20 backtrack 1.688s thompson 0.000105s (16,115x) CPython's own `re` on the same family (it backtracks too): n=16 re.fullmatch 0.002s match=True n=18 re.fullmatch 0.007s match=True n=20 re.fullmatch 0.027s match=True n=22 re.fullmatch 0.116s match=True n=24 re.fullmatch 0.485s match=True each +2 in n multiplies the time by ~4. That is 2^n with a fast constant.

Two things worth noticing. First, the measured wall-clock ratio (16,115×) lands within 1% of the step-count ratio (16,209×) — which is the evidence that the step counter is measuring something real and not an artefact of where the ticks were placed. Across four runs the backtracker took 1.673 s, 1.673 s, 1.673 s and 1.688 s, while the ratio ranged from 15,699× to 17,266×. Note where that spread comes from: not from the backtracker, which is stable to under 1%, but from the simulator's 0.0001 s, which is close enough to timer resolution to be mostly noise. The seconds move; the step counts do not.

The uncomfortable part re.fullmatch is on the same curve. CPython's engine is a backtracker, so it is exponential on this family too — each +2 in n roughly quadruples the time (0.027 → 0.116 → 0.485), which is 2n wearing a fast C constant factor. The constant is genuinely good; at n = 20 it beats this toy's pure-Python backtracker by about 60×. It buys you four more characters of n before you are in the same trouble. Everything on this page about the shipped backtracker is a claim about the re module in your standard library.

7. Design decisions and roads not taken

7.1 One graph, two walkers — instead of two independent engines

The obvious build is a backtracking matcher over the parse tree and a separate NFA simulator, which is how the two are usually presented. Rejected, because it would have made the headline unfalsifiable: two different data structures give you no way to argue that a "step" means the same thing on both sides. Sharing the graph turns the comparison into a controlled experiment, and it paid off directly — §6.5's result (delete seen, get the backtracker's exact counts) is only available to a design where both walkers see the same states.

7.2 A step budget instead of restricting the syntax

(a*)*b makes the backtracker non-terminating. The alternative was to declare nested nullable quantifiers out of subset and never build such a pattern. Rejected: it hides the more interesting of the two failure modes behind a syntax rule, and the budget is not a workaround — it is what production engines actually do. Python's re has no such guard and simply runs until it finishes; engines that must survive hostile patterns either add a match timeout or change algorithm, and Cloudflare's post-mortem chose the latter (§10). GAVE_UP is a faithful third verdict, not a testing convenience.

7.3 An explicit stack instead of recursion

Recursion is shorter and reads better. It also means (a*)*b dies of RecursionError at whatever depth Python allows, which would have made the demo's most interesting row look like a language limit rather than an algorithmic one. The explicit stack costs three lines and makes budget the single stopping condition.

7.4 Postfix instead of an AST

Thompson's construction is naturally a stack machine — pop the fragments an operator needs, push one back — so postfix makes compile_nfa one flat loop with no recursion. An AST would need a recursive walk to say the same thing. The cost is that error messages are poor and the empty alternative a| is unsupported (it pops a fragment that was never pushed); see §8.

7.5 No DFA, which is the famous absent third algorithm

The standard next move is to convert the NFA to a DFA, or better, to cache DFA states lazily as they are discovered — this is what makes grep and RE2 fast rather than merely bounded. It is left out because it changes the subject. The DFA's win is over Thompson simulation (it removes the per-character set manipulation); it has nothing to say about backtracking, which is what this toy is comparing. Adding it would double the code and give the reader three curves where the interesting story is between two. Cox's second article (§10) is where to go next.

7.6 No leftmost-first submatch semantics

The greedy parameter of §6.3 is only defensible because this toy has no capture groups. The moment an engine has to report what (a*) matched, the order it explores branches in becomes observable in the output, and greedy stops being a strategy and becomes part of the specification. This is the real reason production backtrackers cannot simply adopt the 41-step ordering — and it is also why Thompson simulation as written here cannot report captures at all. Pike's VM adds per-thread capture slots to fix that; it is the subject of §10's second Cox article.


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

The NFA for pathological(20) has 61 states and the input is 20 characters. What is the largest step count the simulator could possibly report, and why is 841 below it?

Answer

61 states × 21 positions (0 through 20 inclusive) = 1,281. A state can be live at a position only once, because seen refuses a second arrival, so the total number of surviving arrivals cannot exceed the number of (state, position) pairs that exist. 841 < 1,281 as demo.py prints.

The bound is the entire guarantee, and it is why the closed form 2n(n+1)+1 is quadratic rather than exponential: both factors — states and positions — grow linearly in n, so their product grows quadratically. The backtracker's 13,631,486 exceeds 1,281 by a factor of 10,641, which it can only do by arriving at the same (state, position) pair over and over.

Question 2

Without running it: is a?a?a?aaa against "aaa" cheaper or dearer for the backtracker than against "aab"?

Answer

Dearer against "aaa" — which is the counter-intuitive half. "aaa" matches, and the closed form gives (3+6)·2² − 2 = 34 steps, which demo.py's n=3 row confirms. "aab" fails, and costs 26.

The gap is small because both failures resolve late: "aab" gets two characters deep into every branch before the b refutes it, so most of the tree is explored anyway. Contrast "baa", which fails at position 0 and costs 7 — branching never gets started.

So the lesson is not "matching is cheap and failing is expensive," nor its reverse. It is that cost tracks how late the decision resolves. A match is resolved at the very end by definition, which is why it sits at the top; "baa" is resolved immediately, which is why it sits at the bottom; "aab" is resolved late and lands just below the match. §6.5 is the same observation at n=20, where the three costs are 13,631,486, 12,582,910 and 41.

Question 3

match_backtrack and match_thompson both follow both arrows of a SPLIT. So why is only one of them exponential?

Answer

Because of when they follow the second arrow. The backtracker follows out, runs that entire sub-search to exhaustion, and only then follows out1 — so the two arrows produce two independent subtrees, and n nested SPLITs produce 2n leaves. The simulator follows both arrows into the same set at the same position, where the seen check can notice they have converged and collapse them into one.

Same traversal, different data structure at the frontier: a stack keeps paths apart, a set merges them. §6.5 proves it by deleting the set — match_thompson then reports 13,631,486 at n=20, exactly what match_backtrack reports.

Question 4

§6.3 shows greedy=False turning 13,631,486 steps into 41. Why is that not simply a better default?

Answer

Three reasons, in increasing order of importance.

It only moves the cost: on non-matching input both orders cost 12,582,910 at n=20, identically, because every branch has to be refuted before "no" can be returned.

It doesn't remove the exponent, it relocates it — and the mirror image is easy to build. Take ('a?' * n) + 'b' against ('a' * n) + 'b', where every a? must be taken. Now the orders swap places exactly:

ngreedyskip-first
10223,071
153298,303
20423,145,727

Same two lines, same engine, and now skip-first is the catastrophic one. Neither order is safe; each is merely pathological on a different family.

And decisively: with capture groups, branch order is observable in the output. (a*)(a*) against "aa" must report ('aa', '') under greedy semantics — that's what the specification says * means. Reordering the stack pushes would silently change the answers a real engine gives, so it is not a free optimisation; it is a different language. See §7.6.

Question 5

Your service compiles a user-supplied regex and runs it against user-supplied text, on a thread pool of 16 workers, with a 30-second request timeout. You switch to Thompson simulation. What have you fixed, what have you not fixed, and what have you made worse?

Answer

Fixed: matching is now O(states × positions), so no single request can burn a core for 27 minutes on a crafted input. That is the Cloudflare failure (§10) and it is the one that takes the whole fleet down, because 16 workers stuck on one pathological input each is a total outage, not a slow endpoint.

Not fixed: the pattern is still user-supplied, and states grow with pattern length. a{1000}{1000} — or its longhand — is a large NFA before any matching happens, and the bound is a product, so blowing up the first factor still hurts. Rust's regex handles this with size_limit on the compiled program (§10), not with a matching guarantee. You need a pattern-length or compiled-size cap too.

Made worse: every ordinary request got about 1.5× slower (§6.4 measured 0.59–0.67×), and you lost backreferences and lookaround entirely (§8) — which may simply break existing user patterns. You have traded throughput and expressive power for a tail-latency guarantee. If patterns come from your own source tree and only the text is user-supplied, that trade is much less obviously worth making.

Question 6

demo.py prints identical numbers on every run, but §6.7's seconds differ each time. Which of the two would change if you ran this toy on a different machine, and what does that tell you about which number belongs in a test?

Answer

Only the seconds. Step counts are a pure function of (pattern, text) — no clock, no RNG, no hashing in the counted path — so 13,631,486 and 841 are reproducible anywhere Python runs, and test_the_headline_is_thirteen_million asserts them as exact integers. The 1.688 s is a property of an M1 Max on a particular afternoon and is asserted nowhere.

That is the reason the wall clock sits behind a --timing flag rather than in the default output: a demo whose output changes on every run cannot be diffed, and a claim that cannot be re-checked by the reader is worth less than one that can. The seconds still earn their place — §6.7 uses them to confirm the step counter measures something real, since the two ratios agree within 1% — but they are evidence about the metric, not the metric.


10. Further reading

Every link below was fetched and confirmed live when this was written.