Coverage Report

Created: 2026-08-25 01:10

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
}