From 9576e0888bd80b9718ec47eab5307ef89db3e189 Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Tue, 27 Jan 2026 10:13:41 -0800 Subject: [PATCH 1/9] Changes made:** 1. **nfa.go**: - Added `epsilonClosure []*faState` field to `faState` struct - Added `precomputeEpsilonClosures()`, `precomputeClosuresRecursive()`, `computeClosureForState()`, and `traverseEpsilonsForClosure()` functions - Updated `traverseNFA` to use precomputed closures with fallback for tests 2. **value_matcher.go**: - Call `precomputeEpsilonClosures()` after setting `startTable`, but only when `isNondeterministic` is true The `eClosure` field in `nfaBuffers` was kept for backward compatibility with tests that call `traverseNFA` directly without going through the normal `addPattern` path. --- nfa.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++-- value_matcher.go | 9 ++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/nfa.go b/nfa.go index 8b285e8..bec77bc 100644 --- a/nfa.go +++ b/nfa.go @@ -16,6 +16,7 @@ type faState struct { table *smallTable fieldTransitions []*fieldMatcher isSpinner bool + epsilonClosure []*faState // precomputed epsilon closure including self } /* @@ -181,7 +182,14 @@ func traverseDFA(table *smallTable, val []byte, transitions []*fieldMatcher) []* // and should grow with use and minimize the need for memory allocation. func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, bufs *nfaBuffers, _ printer) []*fieldMatcher { currentStates := bufs.buf1 - currentStates = append(currentStates, &faState{table: table}) + startState := &faState{table: table} + // Compute closure for the start state inline to avoid map lookup + if len(table.epsilons) == 0 { + startState.epsilonClosure = []*faState{startState} + } else { + startState.epsilonClosure = bufs.eClosure.getClosure(startState) + } + currentStates = append(currentStates, startState) nextStates := bufs.buf2 // a lot of the transitions stuff is going to be empty, but on the other hand @@ -200,7 +208,11 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf utf8Byte = valueTerminator } for _, state := range currentStates { - closure := bufs.eClosure.getClosure(state) + closure := state.epsilonClosure + if closure == nil { + // fallback for states without precomputed closure (e.g., in tests) + closure = bufs.eClosure.getClosure(state) + } for _, ecState := range closure { newTransitions.add(ecState.fieldTransitions) ecState.table.step(utf8Byte, stepResult) @@ -238,7 +250,10 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf // we've run out of input bytes so we need to check the current states and their // epsilon closures for matches for _, state := range currentStates { - closure := bufs.eClosure.getClosure(state) + closure := state.epsilonClosure + if closure == nil { + closure = bufs.eClosure.getClosure(state) + } for _, ecState := range closure { newTransitions.add(ecState.fieldTransitions) } @@ -249,6 +264,68 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf return newTransitions.all() } +// precomputeEpsilonClosures walks the automaton starting from the given table +// and precomputes the epsilon closure for every reachable faState. +func precomputeEpsilonClosures(table *smallTable) { + visited := make(map[*smallTable]bool) + precomputeClosuresRecursive(table, visited) +} + +func precomputeClosuresRecursive(table *smallTable, visited map[*smallTable]bool) { + if visited[table] { + return + } + visited[table] = true + + // Process each faState reachable via byte transitions + for _, state := range table.steps { + if state != nil { + computeClosureForState(state) + precomputeClosuresRecursive(state.table, visited) + } + } + // Process each faState reachable via epsilon transitions + for _, eps := range table.epsilons { + computeClosureForState(eps) + precomputeClosuresRecursive(eps.table, visited) + } +} + +func computeClosureForState(state *faState) { + if state.epsilonClosure != nil { + return // already computed + } + + if len(state.table.epsilons) == 0 { + state.epsilonClosure = []*faState{state} + return + } + + closureSet := make(map[*faState]bool) + if !state.table.isEpsilonOnly() { + closureSet[state] = true + } + traverseEpsilonsForClosure(state, state.table.epsilons, closureSet) + + closure := make([]*faState, 0, len(closureSet)) + for s := range closureSet { + closure = append(closure, s) + } + state.epsilonClosure = closure +} + +func traverseEpsilonsForClosure(start *faState, epsilons []*faState, closureSet map[*faState]bool) { + for _, eps := range epsilons { + if eps == start || closureSet[eps] { + continue + } + if !eps.table.isEpsilonOnly() { + closureSet[eps] = true + } + traverseEpsilonsForClosure(start, eps.table.epsilons, closureSet) + } +} + type faStepKey struct { step1 *faState step2 *faState diff --git a/value_matcher.go b/value_matcher.go index 5d73d05..2ef547a 100644 --- a/value_matcher.go +++ b/value_matcher.go @@ -144,6 +144,9 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche // there's already a table, thus an out-degree > 1 if fields.startTable != nil { fields.startTable = mergeFAs(fields.startTable, newFA, printer) + if fields.isNondeterministic { + precomputeEpsilonClosures(fields.startTable) + } m.update(fields) return nextField } @@ -156,11 +159,17 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche // now table is ready for use, nuke singleton to signal threads to use it fields.startTable = mergeFAs(singletonAutomaton, newFA, sharedNullPrinter) + if fields.isNondeterministic { + precomputeEpsilonClosures(fields.startTable) + } fields.singletonMatch = nil fields.singletonTransition = nil } else { // empty valueMatcher, no special cases, just jam in the new FA fields.startTable = newFA + if fields.isNondeterministic { + precomputeEpsilonClosures(fields.startTable) + } } m.update(fields) return nextField From 8880e9b3321569e35f35836a20cbdf49a12fb4cf Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 08:50:24 -0800 Subject: [PATCH 2/9] Remove test path. --- nfa.go | 28 +++------------------------- nfa_test.go | 1 + regexp_nfa_test.go | 4 ++++ regexp_validity_test.go | 3 +++ rune_range_test.go | 1 + shell_style_test.go | 1 + 6 files changed, 13 insertions(+), 25 deletions(-) diff --git a/nfa.go b/nfa.go index 60f6b4d..9ea9fff 100644 --- a/nfa.go +++ b/nfa.go @@ -80,7 +80,6 @@ func (tm *transmap) all() []*fieldMatcher { // allocation will be reduced to nearly zero. type nfaBuffers struct { buf1, buf2 []*faState - eClosure *epsilonClosure matches *matchSet transitionsBuf []*fieldMatcher resultBuf []X @@ -108,13 +107,6 @@ func (nb *nfaBuffers) getBuf2() []*faState { return nb.buf2 } -func (nb *nfaBuffers) getEClosure() *epsilonClosure { - if nb.eClosure == nil { - nb.eClosure = newEpsilonClosure() - } - return nb.eClosure -} - func (nb *nfaBuffers) getMatches() *matchSet { if nb.matches == nil { nb.matches = newMatchSet() @@ -222,12 +214,7 @@ func traverseDFA(table *smallTable, val []byte, transitions []*fieldMatcher) []* func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, bufs *nfaBuffers, _ printer) []*fieldMatcher { currentStates := bufs.getBuf1() startState := &faState{table: table} - // Compute closure for the start state inline to avoid map lookup - if len(table.epsilons) == 0 { - startState.epsilonClosure = []*faState{startState} - } else { - startState.epsilonClosure = bufs.getEClosure().getClosure(startState) - } + computeClosureForState(startState) currentStates = append(currentStates, startState) nextStates := bufs.getBuf2() @@ -248,12 +235,7 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf utf8Byte = valueTerminator } for _, state := range currentStates { - closure := state.epsilonClosure - if closure == nil { - // fallback for states without precomputed closure (e.g., in tests) - closure = bufs.getEClosure().getClosure(state) - } - for _, ecState := range closure { + for _, ecState := range state.epsilonClosure { newTransitions.add(ecState.fieldTransitions) ecState.table.step(utf8Byte, stepResult) if stepResult.step != nil { @@ -290,11 +272,7 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf // we've run out of input bytes so we need to check the current states and their // epsilon closures for matches for _, state := range currentStates { - closure := state.epsilonClosure - if closure == nil { - closure = bufs.getEClosure().getClosure(state) - } - for _, ecState := range closure { + for _, ecState := range state.epsilonClosure { newTransitions.add(ecState.fieldTransitions) } } diff --git a/nfa_test.go b/nfa_test.go index 9e8c167..a25e987 100644 --- a/nfa_test.go +++ b/nfa_test.go @@ -116,6 +116,7 @@ func TestNfa2Dfa(t *testing.T) { bufs := newNfaBuffers() for _, test := range tests { nfa, _ := makeShellStyleFA(asQuotedBytes(t, test.pattern), pp) + precomputeEpsilonClosures(nfa) //fmt.Println("NFA: " + pp.printNFA(nfa)) for _, should := range test.shoulds { diff --git a/regexp_nfa_test.go b/regexp_nfa_test.go index 8c93f41..3b155d6 100644 --- a/regexp_nfa_test.go +++ b/regexp_nfa_test.go @@ -83,6 +83,7 @@ func faFromRegexp(t *testing.T, r string, pp printer) *smallTable { return nil } fa, _ := makeRegexpNFA(parse.tree, true, pp) + precomputeEpsilonClosures(fa) return fa } @@ -251,6 +252,7 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse " + err.Error()) } st, wanted := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) + precomputeEpsilonClosures(st) bufs := newNfaBuffers() for _, r := range runes { // func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, bufs *bufpair) []*fieldMatcher { @@ -275,6 +277,7 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse " + err.Error()) } st, _ := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) + precomputeEpsilonClosures(st) bufs := newNfaBuffers() for _, nonMatch := range nonMatches { found := traverseNFA(st, []byte(nonMatch), nil, bufs, sharedNullPrinter) @@ -299,6 +302,7 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse failure: " + pat) } st, wanted := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) + precomputeEpsilonClosures(st) found := traverseNFA(st, []byte(daodechingorig), nil, bufs, sharedNullPrinter) if len(found) != 1 { t.Errorf("Failed to match ") diff --git a/regexp_validity_test.go b/regexp_validity_test.go index 014316c..cafd8ac 100644 --- a/regexp_validity_test.go +++ b/regexp_validity_test.go @@ -27,6 +27,7 @@ func TestEmptyRegexp(t *testing.T) { fmt.Println("OOPS: " + err.Error()) } table, _ := makeRegexpNFA(parse.tree, false, sharedNullPrinter) + precomputeEpsilonClosures(table) // raw empty string should NOT match var transitions []*fieldMatcher bufs := newNfaBuffers() @@ -66,6 +67,7 @@ func TestToxicStack(t *testing.T) { t.Error("OOPS: " + err.Error()) } table, _ = makeRegexpNFA(parse.tree, true, pp) + precomputeEpsilonClosures(table) var transitions []*fieldMatcher bufs := newNfaBuffers() @@ -121,6 +123,7 @@ func TestRegexpValidity(t *testing.T) { if len(parse.features.foundUnimplemented()) == 0 { implemented++ table, dest := makeRegexpNFA(parse.tree, false, sharedNullPrinter) + precomputeEpsilonClosures(table) for _, should := range sample.matches { var transitions []*fieldMatcher bufs := newNfaBuffers() diff --git a/rune_range_test.go b/rune_range_test.go index c19422c..0f43da2 100644 --- a/rune_range_test.go +++ b/rune_range_test.go @@ -18,6 +18,7 @@ func TestSkinnyRuneTree(t *testing.T) { addSkinnyRuneTreeEntry(srt, r+1, dest) addSkinnyRuneTreeEntry(srt, r+3, dest) fa := nfaFromSkinnyRuneTree(srt, pp) + precomputeEpsilonClosures(fa) fmt.Println("FA:\n" + pp.printNFA(fa)) trans := []*fieldMatcher{} bufs := newNfaBuffers() diff --git a/shell_style_test.go b/shell_style_test.go index 8f180b1..e4b8c8a 100644 --- a/shell_style_test.go +++ b/shell_style_test.go @@ -60,6 +60,7 @@ func TestMakeShellStyleFA(t *testing.T) { for i, pattern := range patterns { a, wanted := makeShellStyleFA([]byte(pattern), sharedNullPrinter) + precomputeEpsilonClosures(a) vm := newValueMatcher() vmf := vmFields{startTable: a} vm.update(&vmf) From 68ab3834373c450f89d7ade2a1f73e231185aa08 Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 09:13:38 -0800 Subject: [PATCH 3/9] Consolidate precomputeEpsilonClosures into NFA creation functions Move precomputeEpsilonClosures calls from test files into the NFA creation functions (makeShellStyleFA, makeWildCardFA, makeRegexpNFA). This ensures epsilon closures are always computed when an NFA is created, eliminating the need for callers to remember to call it. The production code in value_matcher.go still calls it after mergeFAs, which remains necessary because merging creates new states. Since precomputeEpsilonClosures is idempotent, this works correctly. Also changed TestSkinnyRuneTree to use traverseDFA since nfaFromSkinnyRuneTree creates a deterministic FA. Co-Authored-By: Claude Opus 4.5 --- nfa_test.go | 1 - regexp_nfa.go | 4 +++- regexp_nfa_test.go | 4 ---- regexp_validity_test.go | 3 --- rune_range_test.go | 4 +--- shell_style.go | 1 + shell_style_test.go | 1 - wildcard.go | 3 +-- 8 files changed, 6 insertions(+), 15 deletions(-) diff --git a/nfa_test.go b/nfa_test.go index a25e987..9e8c167 100644 --- a/nfa_test.go +++ b/nfa_test.go @@ -116,7 +116,6 @@ func TestNfa2Dfa(t *testing.T) { bufs := newNfaBuffers() for _, test := range tests { nfa, _ := makeShellStyleFA(asQuotedBytes(t, test.pattern), pp) - precomputeEpsilonClosures(nfa) //fmt.Println("NFA: " + pp.printNFA(nfa)) for _, should := range test.shoulds { diff --git a/regexp_nfa.go b/regexp_nfa.go index 11e2d3c..2918585 100644 --- a/regexp_nfa.go +++ b/regexp_nfa.go @@ -29,7 +29,9 @@ func makeRegexpNFA(root regexpRoot, forField bool, pp printer) (*smallTable, *fi pp.labelTable(table, "") nextStep = &faState{table: table} } - return makeNFAFromBranches(root, nextStep, forField, pp), nextField + fa := makeNFAFromBranches(root, nextStep, forField, pp) + precomputeEpsilonClosures(fa) + return fa, nextField } func makeNFAFromBranches(root regexpRoot, nextStep *faState, forField bool, pp printer) *smallTable { // completely empty regexp diff --git a/regexp_nfa_test.go b/regexp_nfa_test.go index 3b155d6..8c93f41 100644 --- a/regexp_nfa_test.go +++ b/regexp_nfa_test.go @@ -83,7 +83,6 @@ func faFromRegexp(t *testing.T, r string, pp printer) *smallTable { return nil } fa, _ := makeRegexpNFA(parse.tree, true, pp) - precomputeEpsilonClosures(fa) return fa } @@ -252,7 +251,6 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse " + err.Error()) } st, wanted := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) - precomputeEpsilonClosures(st) bufs := newNfaBuffers() for _, r := range runes { // func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, bufs *bufpair) []*fieldMatcher { @@ -277,7 +275,6 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse " + err.Error()) } st, _ := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) - precomputeEpsilonClosures(st) bufs := newNfaBuffers() for _, nonMatch := range nonMatches { found := traverseNFA(st, []byte(nonMatch), nil, bufs, sharedNullPrinter) @@ -302,7 +299,6 @@ func TestMakeDotRegexpNFA(t *testing.T) { t.Error("Parse failure: " + pat) } st, wanted := makeRegexpNFA(parsed.tree, false, sharedNullPrinter) - precomputeEpsilonClosures(st) found := traverseNFA(st, []byte(daodechingorig), nil, bufs, sharedNullPrinter) if len(found) != 1 { t.Errorf("Failed to match ") diff --git a/regexp_validity_test.go b/regexp_validity_test.go index cafd8ac..014316c 100644 --- a/regexp_validity_test.go +++ b/regexp_validity_test.go @@ -27,7 +27,6 @@ func TestEmptyRegexp(t *testing.T) { fmt.Println("OOPS: " + err.Error()) } table, _ := makeRegexpNFA(parse.tree, false, sharedNullPrinter) - precomputeEpsilonClosures(table) // raw empty string should NOT match var transitions []*fieldMatcher bufs := newNfaBuffers() @@ -67,7 +66,6 @@ func TestToxicStack(t *testing.T) { t.Error("OOPS: " + err.Error()) } table, _ = makeRegexpNFA(parse.tree, true, pp) - precomputeEpsilonClosures(table) var transitions []*fieldMatcher bufs := newNfaBuffers() @@ -123,7 +121,6 @@ func TestRegexpValidity(t *testing.T) { if len(parse.features.foundUnimplemented()) == 0 { implemented++ table, dest := makeRegexpNFA(parse.tree, false, sharedNullPrinter) - precomputeEpsilonClosures(table) for _, should := range sample.matches { var transitions []*fieldMatcher bufs := newNfaBuffers() diff --git a/rune_range_test.go b/rune_range_test.go index 0f43da2..36e7b49 100644 --- a/rune_range_test.go +++ b/rune_range_test.go @@ -18,11 +18,9 @@ func TestSkinnyRuneTree(t *testing.T) { addSkinnyRuneTreeEntry(srt, r+1, dest) addSkinnyRuneTreeEntry(srt, r+3, dest) fa := nfaFromSkinnyRuneTree(srt, pp) - precomputeEpsilonClosures(fa) fmt.Println("FA:\n" + pp.printNFA(fa)) trans := []*fieldMatcher{} - bufs := newNfaBuffers() - matches := traverseNFA(fa, utf8, trans, bufs, pp) + matches := traverseDFA(fa, utf8, trans) if len(matches) != 1 { t.Error("MISSED") } diff --git a/shell_style.go b/shell_style.go index da50454..c677df0 100644 --- a/shell_style.go +++ b/shell_style.go @@ -77,5 +77,6 @@ func makeShellStyleFA(val []byte, pp printer) (start *smallTable, nextField *fie lastStep := &faState{table: newSmallTable(), fieldTransitions: []*fieldMatcher{nextField}} pp.labelTable(lastStep.table, fmt.Sprintf("last step at %d", valIndex)) state.table.addByteStep(valueTerminator, lastStep) + precomputeEpsilonClosures(start) return } diff --git a/shell_style_test.go b/shell_style_test.go index e4b8c8a..8f180b1 100644 --- a/shell_style_test.go +++ b/shell_style_test.go @@ -60,7 +60,6 @@ func TestMakeShellStyleFA(t *testing.T) { for i, pattern := range patterns { a, wanted := makeShellStyleFA([]byte(pattern), sharedNullPrinter) - precomputeEpsilonClosures(a) vm := newValueMatcher() vmf := vmFields{startTable: a} vm.update(&vmf) diff --git a/wildcard.go b/wildcard.go index 28dfde7..a6681cb 100644 --- a/wildcard.go +++ b/wildcard.go @@ -113,7 +113,6 @@ func makeWildCardFA(val []byte, pp printer) (start *smallTable, nextField *field lastStep := &faState{table: newSmallTable(), fieldTransitions: []*fieldMatcher{nextField}} pp.labelTable(lastStep.table, fmt.Sprintf("last step at %d", valIndex)) state.table.addByteStep(valueTerminator, lastStep) - - // start = nfa2Dfa(start, pp).table + precomputeEpsilonClosures(start) return } From b82a6bf3a39dfe2b41beeafe5e1dfbcf6f979b87 Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 09:39:33 -0800 Subject: [PATCH 4/9] Move new functions to epsi_closure.go, delete old ones. --- epsi_closure.go | 69 +++++++++++++++++++++++----------------- epsi_closure_test.go | 42 ++++++++++++------------ nfa.go | 76 +++++--------------------------------------- v2_bench_test.go | 3 +- 4 files changed, 68 insertions(+), 122 deletions(-) diff --git a/epsi_closure.go b/epsi_closure.go index 1ba60fc..f62198e 100644 --- a/epsi_closure.go +++ b/epsi_closure.go @@ -1,54 +1,63 @@ package quamina -type epsilonClosure struct { - closures map[*faState][]*faState +// precomputeEpsilonClosures walks the automaton starting from the given table +// and precomputes the epsilon closure for every reachable faState. +func precomputeEpsilonClosures(table *smallTable) { + visited := make(map[*smallTable]bool) + precomputeClosuresRecursive(table, visited) } -func newEpsilonClosure() *epsilonClosure { - return &epsilonClosure{make(map[*faState][]*faState)} -} +func precomputeClosuresRecursive(table *smallTable, visited map[*smallTable]bool) { + if visited[table] { + return + } + visited[table] = true -func (ec *epsilonClosure) getClosure(state *faState) []*faState { - var closure []*faState - var ok bool - if ec.closures != nil { - closure, ok = ec.closures[state] - if ok { - return closure + // Process each faState reachable via byte transitions + for _, state := range table.steps { + if state != nil { + computeClosureForState(state) + precomputeClosuresRecursive(state.table, visited) } } + // Process each faState reachable via epsilon transitions + for _, eps := range table.epsilons { + computeClosureForState(eps) + precomputeClosuresRecursive(eps.table, visited) + } +} + +func computeClosureForState(state *faState) { + if state.epsilonClosure != nil { + return // already computed + } - // not already known if len(state.table.epsilons) == 0 { - justMe := []*faState{state} - if ec.closures != nil { - ec.closures[state] = justMe - } - return justMe + state.epsilonClosure = []*faState{state} + return } - var closureStates = make(map[*faState]bool) + closureSet := make(map[*faState]bool) if !state.table.isEpsilonOnly() { - closureStates[state] = true + closureSet[state] = true } - traverseEpsilons(state, state.table.epsilons, closureStates) - for s := range closureStates { + traverseEpsilons(state, state.table.epsilons, closureSet) + + closure := make([]*faState, 0, len(closureSet)) + for s := range closureSet { closure = append(closure, s) } - if ec.closures != nil { - ec.closures[state] = closure - } - return closure + state.epsilonClosure = closure } -func traverseEpsilons(start *faState, epsilons []*faState, closureStates map[*faState]bool) { +func traverseEpsilons(start *faState, epsilons []*faState, closureSet map[*faState]bool) { for _, eps := range epsilons { - if eps == start || closureStates[eps] { + if eps == start || closureSet[eps] { continue } if !eps.table.isEpsilonOnly() { - closureStates[eps] = true + closureSet[eps] = true } - traverseEpsilons(start, eps.table.epsilons, closureStates) + traverseEpsilons(start, eps.table.epsilons, closureSet) } } diff --git a/epsi_closure_test.go b/epsi_closure_test.go index 4fcd5e9..62da49f 100644 --- a/epsi_closure_test.go +++ b/epsi_closure_test.go @@ -6,7 +6,6 @@ import ( func TestEpsilonClosure(t *testing.T) { var st *smallTable - var ec []*faState pp := newPrettyPrinter(4589) @@ -25,17 +24,17 @@ func TestEpsilonClosure(t *testing.T) { pp.labelTable(aSc.table, "aSc") aFM := newFieldMatcher() aSc.fieldTransitions = []*fieldMatcher{aFM} - aEC := newEpsilonClosure() - ec = aEC.getClosure(aSa) - if len(ec) != 1 || !containsState(t, ec, aSa) { - t.Errorf("len(ec) = %d; want 0", len(ec)) + + computeClosureForState(aSa) + if len(aSa.epsilonClosure) != 1 || !containsState(t, aSa.epsilonClosure, aSa) { + t.Errorf("len(ec) = %d; want 1", len(aSa.epsilonClosure)) } - ec = aEC.getClosure(aSstar) - if len(ec) != 1 || !containsState(t, ec, aSstar) { + computeClosureForState(aSstar) + if len(aSstar.epsilonClosure) != 1 || !containsState(t, aSstar.epsilonClosure, aSstar) { t.Error("aSstar") } - ec = aEC.getClosure(aSc) - if len(ec) != 1 || !containsState(t, ec, aSc) { + computeClosureForState(aSc) + if len(aSc.epsilonClosure) != 1 || !containsState(t, aSc.epsilonClosure, aSc) { t.Error("aSc") } @@ -66,19 +65,18 @@ func TestEpsilonClosure(t *testing.T) { pp.labelTable(bSstar.table, "bSstar") pp.labelTable(bSx.table, "bSx") pp.labelTable(bSsplice.table, "bSsplice") - //fmt.Println("B machine: " + pp.printNFA(bSsplice.table)) - bEcShouldBeZero := []*faState{bSa, bSb, bSx, bSstar} + bEcShouldBeOne := []*faState{bSa, bSb, bSx, bSstar} zNames := []string{"bSa", "bSb", "bSx", "bSstar"} - for i, shouldBeZero := range bEcShouldBeZero { - ec = aEC.getClosure(shouldBeZero) - if len(ec) != 1 || !containsState(t, ec, shouldBeZero) { - t.Errorf("should be Zero for %s, isn't", zNames[i]) + for i, state := range bEcShouldBeOne { + computeClosureForState(state) + if len(state.epsilonClosure) != 1 || !containsState(t, state.epsilonClosure, state) { + t.Errorf("should be 1 for %s, isn't", zNames[i]) } } - ec = aEC.getClosure(bSsplice) - if len(ec) != 2 || !containsState(t, ec, bSa) || !containsState(t, ec, bSstar) { + computeClosureForState(bSsplice) + if len(bSsplice.epsilonClosure) != 2 || !containsState(t, bSsplice.epsilonClosure, bSa) || !containsState(t, bSsplice.epsilonClosure, bSstar) { t.Error("wrong EC for b") } @@ -106,14 +104,14 @@ func TestEpsilonClosure(t *testing.T) { st = states[i].table pp.labelTable(st, name) } - // fmt.Println("C machine: " + pp.printNFA(cStart.table)) + + computeClosureForState(cStart) cWantInEC := []*faState{cStart, cSa, cSb, cSc, cSz} - ec = aEC.getClosure(cStart) - if len(ec) != 5 { - t.Errorf("len B ec %d wanted 5", len(ec)) + if len(cStart.epsilonClosure) != 5 { + t.Errorf("len B ec %d wanted 5", len(cStart.epsilonClosure)) } for i, want := range cWantInEC { - if !containsState(t, ec, want) { + if !containsState(t, cStart.epsilonClosure, want) { t.Errorf("C missed %s", names[i]) } } diff --git a/nfa.go b/nfa.go index 9ea9fff..a71c9c1 100644 --- a/nfa.go +++ b/nfa.go @@ -122,10 +122,12 @@ func (nb *nfaBuffers) getTransmap() *transmap { } // nfa2Dfa does what the name says, but as of 2025/12 is not used. +// Requires that precomputeEpsilonClosures has been called on the NFA. func nfa2Dfa(nfaTable *smallTable) *faState { - startNfa := []*faState{{table: nfaTable}} - ec := newEpsilonClosure() - return n2dNode(startNfa, newStateLists(), ec) + startState := &faState{table: nfaTable} + computeClosureForState(startState) + startNfa := []*faState{startState} + return n2dNode(startNfa, newStateLists()) } // n2dNode input is a list of NFA states, which are all the states that are either the @@ -133,11 +135,11 @@ func nfa2Dfa(nfaTable *smallTable) *faState { // a byte transition. // It returns a DFA state (i.e. no epsilons) that corresponds to this aggregation of // NFA states. -func n2dNode(rawNStates []*faState, sList *stateLists, ec *epsilonClosure) *faState { +func n2dNode(rawNStates []*faState, sList *stateLists) *faState { // we expand the raw list of states by adding the epsilon closure of each nStates := make([]*faState, 0, len(rawNStates)) for _, rawNState := range rawNStates { - nStates = append(nStates, ec.getClosure(rawNState)...) + nStates = append(nStates, rawNState.epsilonClosure...) } // the collection of states may have duplicates and, deduplicated, considered' @@ -170,7 +172,7 @@ func n2dNode(rawNStates []*faState, sList *stateLists, ec *epsilonClosure) *faSt rawStates = append(rawStates, ingredients[ingredient].table.epsilons...) } if len(rawStates) > 0 { - dfaState.table.addByteStep(byte(utf8byte), n2dNode(rawStates, sList, ec)) + dfaState.table.addByteStep(byte(utf8byte), n2dNode(rawStates, sList)) } } @@ -282,68 +284,6 @@ func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, buf return newTransitions.all() } -// precomputeEpsilonClosures walks the automaton starting from the given table -// and precomputes the epsilon closure for every reachable faState. -func precomputeEpsilonClosures(table *smallTable) { - visited := make(map[*smallTable]bool) - precomputeClosuresRecursive(table, visited) -} - -func precomputeClosuresRecursive(table *smallTable, visited map[*smallTable]bool) { - if visited[table] { - return - } - visited[table] = true - - // Process each faState reachable via byte transitions - for _, state := range table.steps { - if state != nil { - computeClosureForState(state) - precomputeClosuresRecursive(state.table, visited) - } - } - // Process each faState reachable via epsilon transitions - for _, eps := range table.epsilons { - computeClosureForState(eps) - precomputeClosuresRecursive(eps.table, visited) - } -} - -func computeClosureForState(state *faState) { - if state.epsilonClosure != nil { - return // already computed - } - - if len(state.table.epsilons) == 0 { - state.epsilonClosure = []*faState{state} - return - } - - closureSet := make(map[*faState]bool) - if !state.table.isEpsilonOnly() { - closureSet[state] = true - } - traverseEpsilonsForClosure(state, state.table.epsilons, closureSet) - - closure := make([]*faState, 0, len(closureSet)) - for s := range closureSet { - closure = append(closure, s) - } - state.epsilonClosure = closure -} - -func traverseEpsilonsForClosure(start *faState, epsilons []*faState, closureSet map[*faState]bool) { - for _, eps := range epsilons { - if eps == start || closureSet[eps] { - continue - } - if !eps.table.isEpsilonOnly() { - closureSet[eps] = true - } - traverseEpsilonsForClosure(start, eps.table.epsilons, closureSet) - } -} - type faStepKey struct { step1 *faState step2 *faState diff --git a/v2_bench_test.go b/v2_bench_test.go index 01a0355..1519a00 100644 --- a/v2_bench_test.go +++ b/v2_bench_test.go @@ -9,8 +9,7 @@ import ( // Benchmarks designed to work with Go's 1.24 testing.B.Loop(). Note: When doing this kind of benchmarking, always // call quamina.MatchesForEvent, as opposed to working directly with the coreMatcher, because the top-level function -// is clever about re-using the nfaBuffers structure, which in particular includes the epsilonClosure cache. If you -// work directly with coreMatcher your CPU and memory profiles will be dominated by epsilonClosure. +// is clever about re-using the nfaBuffers structure. func Benchmark8259Example(b *testing.B) { j := `{ From 1615df3ce2aa5e61ea36ae1bd88c7e35ad47ec65 Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 09:48:49 -0800 Subject: [PATCH 5/9] Use the name computeClosureForNfa. --- epsi_closure.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/epsi_closure.go b/epsi_closure.go index f62198e..2be151c 100644 --- a/epsi_closure.go +++ b/epsi_closure.go @@ -4,10 +4,10 @@ package quamina // and precomputes the epsilon closure for every reachable faState. func precomputeEpsilonClosures(table *smallTable) { visited := make(map[*smallTable]bool) - precomputeClosuresRecursive(table, visited) + computeClosureForNfa(table, visited) } -func precomputeClosuresRecursive(table *smallTable, visited map[*smallTable]bool) { +func computeClosureForNfa(table *smallTable, visited map[*smallTable]bool) { if visited[table] { return } @@ -17,13 +17,13 @@ func precomputeClosuresRecursive(table *smallTable, visited map[*smallTable]bool for _, state := range table.steps { if state != nil { computeClosureForState(state) - precomputeClosuresRecursive(state.table, visited) + computeClosureForNfa(state.table, visited) } } // Process each faState reachable via epsilon transitions for _, eps := range table.epsilons { computeClosureForState(eps) - precomputeClosuresRecursive(eps.table, visited) + computeClosureForNfa(eps.table, visited) } } From fc35cb4d1d344fb6976b8941ce230665bd60f87b Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 13:25:52 -0800 Subject: [PATCH 6/9] Remove redundant 'compute' prefixes. --- epsi_closure.go | 14 +++++++------- epsi_closure_test.go | 12 ++++++------ nfa.go | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/epsi_closure.go b/epsi_closure.go index 2be151c..5345598 100644 --- a/epsi_closure.go +++ b/epsi_closure.go @@ -4,10 +4,10 @@ package quamina // and precomputes the epsilon closure for every reachable faState. func precomputeEpsilonClosures(table *smallTable) { visited := make(map[*smallTable]bool) - computeClosureForNfa(table, visited) + closureForNfa(table, visited) } -func computeClosureForNfa(table *smallTable, visited map[*smallTable]bool) { +func closureForNfa(table *smallTable, visited map[*smallTable]bool) { if visited[table] { return } @@ -16,18 +16,18 @@ func computeClosureForNfa(table *smallTable, visited map[*smallTable]bool) { // Process each faState reachable via byte transitions for _, state := range table.steps { if state != nil { - computeClosureForState(state) - computeClosureForNfa(state.table, visited) + closureForState(state) + closureForNfa(state.table, visited) } } // Process each faState reachable via epsilon transitions for _, eps := range table.epsilons { - computeClosureForState(eps) - computeClosureForNfa(eps.table, visited) + closureForState(eps) + closureForNfa(eps.table, visited) } } -func computeClosureForState(state *faState) { +func closureForState(state *faState) { if state.epsilonClosure != nil { return // already computed } diff --git a/epsi_closure_test.go b/epsi_closure_test.go index 62da49f..ea19ed5 100644 --- a/epsi_closure_test.go +++ b/epsi_closure_test.go @@ -25,15 +25,15 @@ func TestEpsilonClosure(t *testing.T) { aFM := newFieldMatcher() aSc.fieldTransitions = []*fieldMatcher{aFM} - computeClosureForState(aSa) + closureForState(aSa) if len(aSa.epsilonClosure) != 1 || !containsState(t, aSa.epsilonClosure, aSa) { t.Errorf("len(ec) = %d; want 1", len(aSa.epsilonClosure)) } - computeClosureForState(aSstar) + closureForState(aSstar) if len(aSstar.epsilonClosure) != 1 || !containsState(t, aSstar.epsilonClosure, aSstar) { t.Error("aSstar") } - computeClosureForState(aSc) + closureForState(aSc) if len(aSc.epsilonClosure) != 1 || !containsState(t, aSc.epsilonClosure, aSc) { t.Error("aSc") } @@ -69,13 +69,13 @@ func TestEpsilonClosure(t *testing.T) { bEcShouldBeOne := []*faState{bSa, bSb, bSx, bSstar} zNames := []string{"bSa", "bSb", "bSx", "bSstar"} for i, state := range bEcShouldBeOne { - computeClosureForState(state) + closureForState(state) if len(state.epsilonClosure) != 1 || !containsState(t, state.epsilonClosure, state) { t.Errorf("should be 1 for %s, isn't", zNames[i]) } } - computeClosureForState(bSsplice) + closureForState(bSsplice) if len(bSsplice.epsilonClosure) != 2 || !containsState(t, bSsplice.epsilonClosure, bSa) || !containsState(t, bSsplice.epsilonClosure, bSstar) { t.Error("wrong EC for b") } @@ -105,7 +105,7 @@ func TestEpsilonClosure(t *testing.T) { pp.labelTable(st, name) } - computeClosureForState(cStart) + closureForState(cStart) cWantInEC := []*faState{cStart, cSa, cSb, cSc, cSz} if len(cStart.epsilonClosure) != 5 { t.Errorf("len B ec %d wanted 5", len(cStart.epsilonClosure)) diff --git a/nfa.go b/nfa.go index a71c9c1..edb873f 100644 --- a/nfa.go +++ b/nfa.go @@ -125,7 +125,7 @@ func (nb *nfaBuffers) getTransmap() *transmap { // Requires that precomputeEpsilonClosures has been called on the NFA. func nfa2Dfa(nfaTable *smallTable) *faState { startState := &faState{table: nfaTable} - computeClosureForState(startState) + closureForState(startState) startNfa := []*faState{startState} return n2dNode(startNfa, newStateLists()) } @@ -216,7 +216,7 @@ func traverseDFA(table *smallTable, val []byte, transitions []*fieldMatcher) []* func traverseNFA(table *smallTable, val []byte, transitions []*fieldMatcher, bufs *nfaBuffers, _ printer) []*fieldMatcher { currentStates := bufs.getBuf1() startState := &faState{table: table} - computeClosureForState(startState) + closureForState(startState) currentStates = append(currentStates, startState) nextStates := bufs.getBuf2() From db6615091526071a9a91ae2fd5cc39eed11c8d28 Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 13:48:41 -0800 Subject: [PATCH 7/9] Rename precomputeEpsilonClosure to epsilonClosure. --- epsi_closure.go | 4 ++-- regexp_nfa.go | 2 +- shell_style.go | 2 +- value_matcher.go | 6 +++--- wildcard.go | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/epsi_closure.go b/epsi_closure.go index 5345598..7222cb9 100644 --- a/epsi_closure.go +++ b/epsi_closure.go @@ -1,8 +1,8 @@ package quamina -// precomputeEpsilonClosures walks the automaton starting from the given table +// epsilonClosure walks the automaton starting from the given table // and precomputes the epsilon closure for every reachable faState. -func precomputeEpsilonClosures(table *smallTable) { +func epsilonClosure(table *smallTable) { visited := make(map[*smallTable]bool) closureForNfa(table, visited) } diff --git a/regexp_nfa.go b/regexp_nfa.go index 2918585..ca3497e 100644 --- a/regexp_nfa.go +++ b/regexp_nfa.go @@ -30,7 +30,7 @@ func makeRegexpNFA(root regexpRoot, forField bool, pp printer) (*smallTable, *fi nextStep = &faState{table: table} } fa := makeNFAFromBranches(root, nextStep, forField, pp) - precomputeEpsilonClosures(fa) + epsilonClosure(fa) return fa, nextField } func makeNFAFromBranches(root regexpRoot, nextStep *faState, forField bool, pp printer) *smallTable { diff --git a/shell_style.go b/shell_style.go index c677df0..bb7bfaa 100644 --- a/shell_style.go +++ b/shell_style.go @@ -77,6 +77,6 @@ func makeShellStyleFA(val []byte, pp printer) (start *smallTable, nextField *fie lastStep := &faState{table: newSmallTable(), fieldTransitions: []*fieldMatcher{nextField}} pp.labelTable(lastStep.table, fmt.Sprintf("last step at %d", valIndex)) state.table.addByteStep(valueTerminator, lastStep) - precomputeEpsilonClosures(start) + epsilonClosure(start) return } diff --git a/value_matcher.go b/value_matcher.go index 2ef547a..227a139 100644 --- a/value_matcher.go +++ b/value_matcher.go @@ -145,7 +145,7 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche if fields.startTable != nil { fields.startTable = mergeFAs(fields.startTable, newFA, printer) if fields.isNondeterministic { - precomputeEpsilonClosures(fields.startTable) + epsilonClosure(fields.startTable) } m.update(fields) return nextField @@ -160,7 +160,7 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche // now table is ready for use, nuke singleton to signal threads to use it fields.startTable = mergeFAs(singletonAutomaton, newFA, sharedNullPrinter) if fields.isNondeterministic { - precomputeEpsilonClosures(fields.startTable) + epsilonClosure(fields.startTable) } fields.singletonMatch = nil fields.singletonTransition = nil @@ -168,7 +168,7 @@ func (m *valueMatcher) addTransition(val typedVal, printer printer) *fieldMatche // empty valueMatcher, no special cases, just jam in the new FA fields.startTable = newFA if fields.isNondeterministic { - precomputeEpsilonClosures(fields.startTable) + epsilonClosure(fields.startTable) } } m.update(fields) diff --git a/wildcard.go b/wildcard.go index a6681cb..9262706 100644 --- a/wildcard.go +++ b/wildcard.go @@ -113,6 +113,6 @@ func makeWildCardFA(val []byte, pp printer) (start *smallTable, nextField *field lastStep := &faState{table: newSmallTable(), fieldTransitions: []*fieldMatcher{nextField}} pp.labelTable(lastStep.table, fmt.Sprintf("last step at %d", valIndex)) state.table.addByteStep(valueTerminator, lastStep) - precomputeEpsilonClosures(start) + epsilonClosure(start) return } From 213360f48776963611cbc663d2369f3894216f7c Mon Sep 17 00:00:00 2001 From: Robert Sayre Date: Thu, 29 Jan 2026 14:15:44 -0800 Subject: [PATCH 8/9] Adjust epsilonClosure. --- epsi_closure.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/epsi_closure.go b/epsi_closure.go index 7222cb9..cad8da9 100644 --- a/epsi_closure.go +++ b/epsi_closure.go @@ -3,8 +3,7 @@ package quamina // epsilonClosure walks the automaton starting from the given table // and precomputes the epsilon closure for every reachable faState. func epsilonClosure(table *smallTable) { - visited := make(map[*smallTable]bool) - closureForNfa(table, visited) + closureForNfa(table, make(map[*smallTable]bool)) } func closureForNfa(table *smallTable, visited map[*smallTable]bool) { From bf3290a220e2195e70c3dd1df8cadd27a27aef58 Mon Sep 17 00:00:00 2001 From: sayrer Date: Thu, 29 Jan 2026 16:56:13 -0800 Subject: [PATCH 9/9] Add some tests for epsilon closures. --- value_matcher_test.go | 165 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/value_matcher_test.go b/value_matcher_test.go index 538a515..ba82de8 100644 --- a/value_matcher_test.go +++ b/value_matcher_test.go @@ -418,3 +418,168 @@ func TestMergeNfaAndNumeric(t *testing.T) { } } } + +// TestEpsilonClosureAfterMerge verifies that when a deterministic pattern +// is merged into an NFA that already has epsilon transitions, the newly +// created splice states get their epsilon closures computed. +func TestEpsilonClosureAfterMerge(t *testing.T) { + vm := newValueMatcher() + + // Add a wildcard pattern first - this sets isNondeterministic=true + // and creates an NFA with epsilon transitions + wildcardVal := typedVal{ + vType: wildcardType, + val: "a*b", + } + vm.addTransition(wildcardVal, sharedNullPrinter) + + fields := vm.fields() + if !fields.isNondeterministic { + t.Error("expected isNondeterministic=true after wildcard") + } + + // Now add a simple string pattern - this will merge into the existing NFA + // and create new splice states that need epsilon closure computation + stringVal := typedVal{ + vType: stringType, + val: "xyz", + } + vm.addTransition(stringVal, sharedNullPrinter) + + fields = vm.fields() + if !fields.isNondeterministic { + t.Error("expected isNondeterministic=true to remain set") + } + + // Walk the automaton and verify all states have epsilon closures computed + visited := make(map[*smallTable]bool) + missingClosures := checkEpsilonClosures(fields.startTable, visited) + if len(missingClosures) > 0 { + t.Errorf("found %d states with missing epsilon closures", len(missingClosures)) + } + + // Verify the matcher actually works + bufs := &nfaBuffers{} + // Should match wildcard pattern "a*b" + trans := vm.transitionOn(&Field{Val: []byte("aXXXb")}, bufs) + if len(trans) != 1 { + t.Errorf("expected 1 transition for 'aXXXb', got %d", len(trans)) + } + // Should match string pattern "xyz" + trans = vm.transitionOn(&Field{Val: []byte("xyz")}, bufs) + if len(trans) != 1 { + t.Errorf("expected 1 transition for 'xyz', got %d", len(trans)) + } + // Should not match + trans = vm.transitionOn(&Field{Val: []byte("nomatch")}, bufs) + if len(trans) != 0 { + t.Errorf("expected 0 transitions for 'nomatch', got %d", len(trans)) + } +} + +// checkEpsilonClosures walks the automaton and returns states that have +// epsilon transitions but no computed epsilon closure. +func checkEpsilonClosures(table *smallTable, visited map[*smallTable]bool) []*faState { + var missing []*faState + if visited[table] { + return missing + } + visited[table] = true + + for _, state := range table.steps { + if state != nil { + if len(state.table.epsilons) > 0 && state.epsilonClosure == nil { + missing = append(missing, state) + } + missing = append(missing, checkEpsilonClosures(state.table, visited)...) + } + } + for _, eps := range table.epsilons { + if eps.epsilonClosure == nil { + missing = append(missing, eps) + } + missing = append(missing, checkEpsilonClosures(eps.table, visited)...) + } + return missing +} + +// TestEpsilonClosureRequired demonstrates that epsilonClosure must be called +// after merging into an NFA. This test simulates what would happen if we +// skipped the epsilonClosure call by clearing the closures after merge. +func TestEpsilonClosureRequired(t *testing.T) { + vm := newValueMatcher() + + // Add a wildcard pattern first - creates NFA with epsilon transitions + wildcardVal := typedVal{ + vType: wildcardType, + val: "a*z", + } + vm.addTransition(wildcardVal, sharedNullPrinter) + + // Add a string pattern - this triggers merge and epsilonClosure call + stringVal := typedVal{ + vType: stringType, + val: "abc", + } + vm.addTransition(stringVal, sharedNullPrinter) + + bufs := &nfaBuffers{} + + // Step 1: Verify matching works with closures computed + trans := vm.transitionOn(&Field{Val: []byte("abc")}, bufs) + if len(trans) != 1 { + t.Fatalf("with closures: expected 1 transition for 'abc', got %d", len(trans)) + } + trans = vm.transitionOn(&Field{Val: []byte("aXXXz")}, bufs) + if len(trans) != 1 { + t.Fatalf("with closures: expected 1 transition for 'aXXXz', got %d", len(trans)) + } + + // Step 2: Clear all epsilon closures to simulate missing epsilonClosure call + fields := vm.fields() + clearEpsilonClosures(fields.startTable, make(map[*smallTable]bool)) + + // Step 3: Without closures, traverseNFA fails because it iterates over + // state.epsilonClosure which is now nil (empty loop = no matches) + trans = vm.transitionOn(&Field{Val: []byte("abc")}, bufs) + abcMatchedWithoutClosures := len(trans) == 1 + + trans = vm.transitionOn(&Field{Val: []byte("aXXXz")}, bufs) + wildcardMatchedWithoutClosures := len(trans) == 1 + + // At least one pattern must fail without closures to prove they're needed + if abcMatchedWithoutClosures && wildcardMatchedWithoutClosures { + t.Fatal("both patterns matched without closures - epsilonClosure is not needed (test invalid)") + } + + // Step 4: Restore closures and verify matching works again + epsilonClosure(fields.startTable) + + trans = vm.transitionOn(&Field{Val: []byte("abc")}, bufs) + if len(trans) != 1 { + t.Errorf("after restore: expected 1 transition for 'abc', got %d", len(trans)) + } + trans = vm.transitionOn(&Field{Val: []byte("aXXXz")}, bufs) + if len(trans) != 1 { + t.Errorf("after restore: expected 1 transition for 'aXXXz', got %d", len(trans)) + } +} + +// clearEpsilonClosures walks the automaton and sets all epsilonClosure fields to nil +func clearEpsilonClosures(table *smallTable, visited map[*smallTable]bool) { + if visited[table] { + return + } + visited[table] = true + + for _, state := range table.steps { + if state != nil { + state.epsilonClosure = nil + clearEpsilonClosures(state.table, visited) + } + } + for _, eps := range table.epsilons { + eps.epsilonClosure = nil + clearEpsilonClosures(eps.table, visited) + } +}