real-regex/tests/engine/test_route_pinning.cpp
Line | Count | Source |
1 | | // P0: deterministic route-pinning — a regression of recognition is a silent perf loss. |
2 | | // Gate-safe (hints / seams only; no wall-clock). Profile-OFF. |
3 | | #include <sciforge/test/framework.hpp> |
4 | | |
5 | | #include <real/automata/lazy_dfa.hpp> |
6 | | #include <real/real.hpp> |
7 | | |
8 | | #include <string> |
9 | | #include <string_view> |
10 | | |
11 | | namespace { |
12 | | |
13 | | // Pad past lazy_dfa_min_input (512) so search routes actually engage. |
14 | | std::string pad(std::string_view unit, |
15 | | std::size_t bytes = 700) |
16 | 2 | { |
17 | 2 | std::string s; |
18 | 2 | s.reserve(bytes + unit.size()); |
19 | 36 | while (s.size() < bytes) { |
20 | 34 | s += unit; |
21 | 34 | } |
22 | 2 | return s; |
23 | 2 | } |
24 | | } // namespace |
25 | | |
26 | | TEST(route_pin_w_plus_is_cp_class_loop) |
27 | 1 | { |
28 | 1 | const real::regex re {R"(\w+)"}; |
29 | 1 | const auto prog {re.raw_program()}; |
30 | 1 | EXPECT(prog.hints.greedy_cp_class >= 0); |
31 | 1 | EXPECT(prog.hints.greedy_cp_class_plus); |
32 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_class_loop), -1); |
33 | 1 | } |
34 | | |
35 | | TEST(route_pin_az_plus_is_class_loop) |
36 | 1 | { |
37 | 1 | const real::regex re {R"([a-z]+)"}; |
38 | 1 | const auto prog {re.raw_program()}; |
39 | 1 | EXPECT(prog.hints.greedy_class_loop >= 0); |
40 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_cp_class), -1); |
41 | 1 | } |
42 | | |
43 | | TEST(route_pin_dog_is_exact_literal) |
44 | 1 | { |
45 | 1 | const real::regex re {"dog"}; |
46 | 1 | EXPECT(re.raw_program().hints.exact_literal_len == 3); |
47 | 1 | } |
48 | | |
49 | | TEST(route_pin_bw_b1_drop_and_cp) |
50 | 1 | { |
51 | | // Arc B-1: `\b\w+\b` simplifies to bare greedy_cp (wb dropped). |
52 | 1 | const real::regex re {R"(\b\w+\b)"}; |
53 | 1 | const auto prog {re.raw_program()}; |
54 | 1 | EXPECT(prog.hints.greedy_cp_class >= 0); |
55 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.wb_lead), 0); |
56 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.wb_trail), 0); |
57 | 1 | } |
58 | | |
59 | | TEST(route_pin_baz_b2_wrap) |
60 | 1 | { |
61 | | // Arc B-2: subset under `\b` keeps wrap hints on class-loop. |
62 | 1 | const real::regex re {R"(\b[a-z]+\b)"}; |
63 | 1 | const auto prog {re.raw_program()}; |
64 | 1 | EXPECT(prog.hints.greedy_class_loop >= 0); |
65 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.wb_lead), 1); |
66 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.wb_trail), 1); |
67 | 1 | } |
68 | | |
69 | | TEST(route_pin_superset_stays_general) |
70 | 1 | { |
71 | | // Recognition gap / unsound wrap: `\w` ∪ emoji must NOT arm greedy_cp under `\b`. |
72 | 1 | std::string pat = "\\b[\\w"; |
73 | 1 | pat += "\xF0\x9F\x98\x80"; // 😀 |
74 | 1 | pat += "]+\\b"; |
75 | 1 | const real::regex re {pat}; |
76 | 1 | const auto prog {re.raw_program()}; |
77 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_cp_class), -1); |
78 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_class_loop), -1); |
79 | 1 | } |
80 | | |
81 | | TEST(route_pin_email_not_dedicated_fastpath) |
82 | 1 | { |
83 | | // Capturing email shape must not steal class/cp/literal fast paths — lazy-DFA / general owns it. |
84 | 1 | const real::regex re {R"((\w+)@(\w+))"}; |
85 | 1 | const auto prog {re.raw_program()}; |
86 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_cp_class), -1); |
87 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.greedy_class_loop), -1); |
88 | 1 | EXPECT_EQ(static_cast<int>(prog.hints.exact_literal_len), 0); |
89 | | // Search on a long corpus still matches (route is DFA-or-general, not a dedicated class loop). |
90 | 1 | const std::string text {pad("contact john.doe@example.com or jane@corp.io today ")}; |
91 | 1 | EXPECT(static_cast<bool>(re.search(text))); |
92 | 1 | } |
93 | | |
94 | | TEST(route_pin_alt_is_fixed_alternation) |
95 | 1 | { |
96 | 1 | const real::regex re {"dog|fox|cat"}; |
97 | 1 | EXPECT(re.raw_program().hints.fixed_alternation); |
98 | 1 | } |
99 | | |
100 | | TEST(route_pin_class_fastpath_seam_falls_through) |
101 | 1 | { |
102 | | // Seam: disabling class/cp fast paths must not change spans (transparency). |
103 | 1 | const std::string text {pad("the quick brown fox jumps 42 times ")}; |
104 | 1 | const real::regex re {R"(\w+)"}; |
105 | | |
106 | 1 | real::detail::class_fastpath_disabled() = false; |
107 | 1 | std::size_t n_on = re.count_matches(text); |
108 | 1 | real::detail::class_fastpath_disabled() = true; |
109 | 1 | std::size_t n_off = re.count_matches(text); |
110 | 1 | real::detail::class_fastpath_disabled() = false; |
111 | | |
112 | 1 | EXPECT_EQ(n_on, n_off); |
113 | 1 | EXPECT(n_on > 0); |
114 | 1 | } |
115 | | |
116 | | // The one-pass extractor is by far the most expensive thing the per-regex cache holds -- measured on a |
117 | | // first `(\w+)@(\w+)` search, 884 us against 331 for the byte program and 117 for the lazy DFA. It used |
118 | | // to be built by ensure_immutables() alongside the byte program, so EVERY route that needed the cheap |
119 | | // half paid for the expensive one, including patterns with no capture to extract at all. Splitting it |
120 | | // behind its own identity flag took that first search from 1490 to 573 us on arm64 and 1958 to 813 on |
121 | | // x86-64, and `\d{4}-\d{2}-\d{2}` (no match, memmem-only) from 296 to 167. |
122 | | // |
123 | | // This pins the split itself, not a duration: if a route change starts building the extractor on a |
124 | | // search that never extracts through it, the win is gone and nothing else would say so. A future route |
125 | | // that legitimately needs it here must show the measurement that justifies the cost. |
126 | | TEST(capture_free_search_does_not_build_the_onepass_extractor) |
127 | 1 | { |
128 | | // SHORT, deliberately: below lazy_dfa_min_input (512) the search does not take the lazy-DFA route, |
129 | | // which DOES extract through the table and so builds it (correctly). The saving is on a short first |
130 | | // search -- which is exactly what the criterion first_use/ group measures (its subject is 80 bytes) |
131 | | // and what a program doing one small match pays. Padding this past 512 engages the DFA route and the |
132 | | // table is built again; that is the route working as intended, not a regression. |
133 | 1 | const std::string text {"say alpha@beta now"}; |
134 | | |
135 | | // 2 slots: the whole-match span and nothing else -- there is no capture for an extractor to fill. |
136 | 1 | const real::regex bare(R"(\w+@\w+)"); |
137 | 1 | EXPECT_EQ(bare.raw_program().slot_count, 2U); |
138 | 1 | EXPECT(bare.search(text).matched()); |
139 | 1 | const real::detail::regex_immutables* const bi {bare.raw_program().immut}; |
140 | 1 | EXPECT(bi != nullptr); |
141 | | // NOT built at all now, and that is the stronger property. The inner-literal route's small-haystack |
142 | | // guard used to run AFTER ensure_immutables and then abandon on the very next line, so a short |
143 | | // subject paid the whole byte-program expansion to learn it was too short to want it. Moving the |
144 | | // guard above the build is exact -- il_min_haystack clamps at 64 KB and so is never below |
145 | | // il_warm_floor's 4 KB, meaning a haystack under 4 KB abandons whichever floor applies. Measured on |
146 | | // arm64 with one live regex per sample (200 kept alive, one first search each -- see |
147 | | // docs/BENCHMARKS.md on why construct-and-destroy loops cannot measure a first search): |
148 | | // |
149 | | // \w+@\w+ 18 bytes 459.51 -> 0.71 us 647x |
150 | | // (\w+)X(\w+) 13 bytes 466.91 -> 1.23 380x |
151 | | // (\w+)@(\w+) 80 bytes 458.60 -> 2.97 154x |
152 | | // [a-z]+@[a-z]+ gauge 3.65 -> 2.16 1.7x |
153 | | // \w+@\w+ 8 KB 1804.59 -> 1720.72 1.05x (past the floor: guard never fires) |
154 | | // |
155 | | // The gauge moves too, because the saving is the BUILD, not anything specific to Unicode classes; |
156 | | // the 8 KB row is the control that shows the guard is size-gated and not simply always-on. The |
157 | | // mechanism is confirmed without a clock: a build counter reads 400 UTF-8 trie builds over those |
158 | | // 200 searches before, and 0 after. |
159 | 1 | EXPECT(bi->built_for.load(std::memory_order_acquire) == nullptr); // neither half was built |
160 | 1 | EXPECT(bi->op_table_for.load(std::memory_order_acquire) == nullptr); |
161 | 1 | EXPECT(!bi->op_table.has_value()); |
162 | | |
163 | | // A no-match scan is memmem-only and likewise never extracts. |
164 | 1 | const real::regex nomatch(R"(\d{4}-\d{2}-\d{2})"); |
165 | 1 | EXPECT(!nomatch.search(text).matched()); |
166 | 1 | const real::detail::regex_immutables* const ni {nomatch.raw_program().immut}; |
167 | 1 | EXPECT(ni != nullptr); |
168 | 1 | EXPECT(ni->op_table_for.load(std::memory_order_acquire) == nullptr); |
169 | | |
170 | | // The extractor is still built where captures ARE filled through it, so the split cost nothing: |
171 | | // an anchored full-match on a one-pass pattern is the route that consults it. |
172 | 1 | const real::regex anchored(R"((\w+)@(\w+))"); |
173 | 1 | EXPECT(anchored.fullmatch("alpha@beta").matched()); |
174 | 1 | const real::detail::regex_immutables* const ai {anchored.raw_program().immut}; |
175 | 1 | EXPECT(ai != nullptr); |
176 | 1 | EXPECT(ai->op_table_for.load(std::memory_order_acquire) != nullptr); |
177 | 1 | EXPECT(ai->op_table.has_value()); |
178 | 1 | } |