]> git.ipfire.org Git - thirdparty/vectorscan.git/commitdiff
test: add comprehensive unit tests for vermicelli and noodle accelerators (#380)
authorByeonguk Jeong <jungbu2855@gmail.com>
Tue, 7 Apr 2026 09:59:57 +0000 (18:59 +0900)
committerGitHub <noreply@github.com>
Tue, 7 Apr 2026 09:59:57 +0000 (12:59 +0300)
* vermicelli: Add vermicelli accelerator unit tests for edge cases

Add 50 new tests in vermicelli_extra.cpp covering:
- Small buffer paths (1 byte to VECTORSIZE-1)
- Exact VECTORSIZE and VECTORSIZE+1 boundary cases
- Per-position match sweep for all 7 vermicelli functions
- Reverse double vermicelli NoMatch (previously missing)
- Forward/reverse consistency checks
- Alignment stress tests
- Double vermicelli SIMD cross-boundary sweep
- Masked double vermicelli with partial bit masks
- Non-alphabetic character matching

Signed-off-by: Byeonguk Jeong <jungbu2855@gmail.com>
* gtest: initialize variable to suppress -Wuninitialized warning

Initialize 'dummy' variable in StackLowerThanAddress() to zero to
avoid potential undefined behavior and compiler warning.

Signed-off-by: Byeonguk Jeong <jungbu2855@gmail.com>
* sheng: correct DFA state transition table sizes

Add missing sentinel element to state transition vectors for both
16-state and 32-state DFA test configurations. The alpha_size includes
a sentinel entry at index alpha_size-1, so each state's next vector
must have alpha_size elements.

Fixes: d0325401f296 ("Add sheng tests")
Signed-off-by: Byeonguk Jeong <jungbu2855@gmail.com>
* noodle: add comprehensive noodle engine unit tests

Add tests covering edge cases and broader scenarios:
- Early termination via callback (single and double char)
- No-match scenarios for single and double patterns
- Empty and minimal-length buffer handling
- Large buffer scanning (multi-vector iteration)
- Case-insensitive matching for single and double patterns
- Unaligned buffer scanning
- Various alignment boundary conditions
- All-match dense buffers for single and double patterns

Signed-off-by: Byeonguk Jeong <jungbu2855@gmail.com>
---------

Signed-off-by: Byeonguk Jeong <jungbu2855@gmail.com>
src/nfa/vermicelli_sve.h
unit/CMakeLists.txt
unit/gtest/gtest-all.cc
unit/internal/noodle.cpp
unit/internal/sheng.cpp
unit/internal/vermicelli_extra.cpp [new file with mode: 0644]

index 184be1bfa96e99c8aa2911c37c5b1adec525b155..6088b94d9c3e49c1f4a0e46d2c7578b543bfaee7 100644 (file)
@@ -535,6 +535,7 @@ const u8 *dvermSearchAlignedMasked(m128 chars1, m128 chars2,
     return NULL;
 }
 
+// TODO: implement with SVE
 static really_inline
 const u8 *vermicelliDoubleMaskedExec(char c1, char c2, char m1, char m2,
                                      const u8 *buf, const u8 *buf_end) {
index 7e16f33301de0b223324594fc877c1aff8676cc8..dd0ad4c5f6d2de91bda5a287bba9d63abd00d888 100644 (file)
@@ -115,6 +115,7 @@ set(unit_internal_SOURCES
     internal/utf8_validate.cpp
     internal/util_string.cpp
     internal/vermicelli.cpp
+    internal/vermicelli_extra.cpp
     internal/main.cpp
     )
 if (BUILD_AVX2)
index 57766c69d8d3ed4653249fa9f9fd51203d657267..57bf64f8d9a3888af291c165aceb747cf163dc43 100644 (file)
@@ -7475,7 +7475,7 @@ static int ExecDeathTestChildMain(void* child_arg) {
 // correct answer.
 void StackLowerThanAddress(const void* ptr, bool* result) GTEST_NO_INLINE_;
 void StackLowerThanAddress(const void* ptr, bool* result) {
-  int dummy;
+  int dummy{0};
   *result = (&dummy < ptr);
 }
 
index c1723744bf4ce700b18b7b4af50185e35080a6da..36092955a8066b1001dce55d4d80d1df89407598 100644 (file)
@@ -260,3 +260,416 @@ TEST(Noodle, noodCutoverDouble) {
     ctxt.clear();
 }
 
+// --- Additional tests for SVE and general edge case coverage ---
+
+// Test: callback that terminates matching early
+static
+hwlmcb_rv_t hlmTerminateAfterN(size_t to, u32 id,
+                               UNUSED struct hs_scratch *scratch) {
+    ctxt.push_back(hlmMatchEntry(to, id));
+    if (ctxt.size() >= 3) {
+        return HWLM_TERMINATE_MATCHING;
+    }
+    return HWLM_CONTINUE_MATCHING;
+}
+
+TEST(Noodle, noodTerminateSingle) {
+    // Fill buffer with 'a' and terminate after 3 matches
+    const size_t data_len = 256;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+
+    u32 id = 1000;
+    hwlmLiteral lit(std::string("a", 1), false, id);
+    auto n = noodBuildTable(lit);
+    ASSERT_TRUE(static_cast<bool>(n));
+
+    struct hs_scratch scratch;
+    hwlm_error_t rv = noodExec(n.get(), data, data_len, 0,
+                               hlmTerminateAfterN, &scratch);
+    ASSERT_EQ(HWLM_TERMINATED, rv);
+    ASSERT_EQ(3U, ctxt.size());
+    ctxt.clear();
+}
+
+TEST(Noodle, noodTerminateDouble) {
+    // Fill buffer with 'a' and terminate after 3 double matches
+    const size_t data_len = 256;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+
+    u32 id = 1000;
+    hwlmLiteral lit(std::string("aa", 2), false, id);
+    auto n = noodBuildTable(lit);
+    ASSERT_TRUE(static_cast<bool>(n));
+
+    struct hs_scratch scratch;
+    hwlm_error_t rv = noodExec(n.get(), data, data_len, 0,
+                               hlmTerminateAfterN, &scratch);
+    ASSERT_EQ(HWLM_TERMINATED, rv);
+    ASSERT_EQ(3U, ctxt.size());
+    ctxt.clear();
+}
+
+// Test: no match at all
+TEST(Noodle, noodNoMatchSingle) {
+    const size_t data_len = 512;
+    u8 data[data_len];
+    memset(data, 'b', data_len);
+
+    noodleMatch(data, data_len, "a", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(0U, ctxt.size());
+    ctxt.clear();
+}
+
+TEST(Noodle, noodNoMatchDouble) {
+    const size_t data_len = 512;
+    u8 data[data_len];
+    memset(data, 'b', data_len);
+
+    noodleMatch(data, data_len, "ac", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(0U, ctxt.size());
+    ctxt.clear();
+}
+
+// Test: very short buffers (edge cases for scan_len checks)
+TEST(Noodle, noodShortBufferSingle) {
+    u8 data[1] = {'x'};
+
+    noodleMatch(data, 1, "x", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(0U, ctxt[0].to);
+    ctxt.clear();
+
+    noodleMatch(data, 1, "y", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(0U, ctxt.size());
+    ctxt.clear();
+}
+
+TEST(Noodle, noodShortBufferDouble) {
+    // 2 bytes: minimum for a double scan
+    u8 data2[2] = {'a', 'b'};
+    noodleMatch(data2, 2, "ab", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(1U, ctxt[0].to);
+    ctxt.clear();
+
+    // 3 bytes
+    u8 data3[3] = {'a', 'b', 'c'};
+    noodleMatch(data3, 3, "bc", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(2U, ctxt[0].to);
+    ctxt.clear();
+
+    // 2 bytes, no match
+    noodleMatch(data2, 2, "ba", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(0U, ctxt.size());
+    ctxt.clear();
+}
+
+// Test: NUL byte in patterns (exercises key1 == '\0' path in scanDoubleOnce)
+TEST(Noodle, noodNulByteSingle) {
+    const size_t data_len = 64;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+    data[10] = '\0';
+    data[30] = '\0';
+
+    noodleMatch(data, data_len, "\0", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(2U, ctxt.size());
+    ASSERT_EQ(10U, ctxt[0].to);
+    ASSERT_EQ(30U, ctxt[1].to);
+    ctxt.clear();
+}
+
+TEST(Noodle, noodNulByteDouble) {
+    const size_t data_len = 64;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+    // Create "a\0" pattern at position 10 and 30
+    data[11] = '\0';
+    data[31] = '\0';
+
+    noodleMatch(data, data_len, "a\0", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(2U, ctxt.size());
+    ASSERT_EQ(11U, ctxt[0].to);
+    ASSERT_EQ(31U, ctxt[1].to);
+    ctxt.clear();
+}
+
+TEST(Noodle, noodNulByteDoubleReverse) {
+    // Pattern: "\0a"
+    const size_t data_len = 64;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+    data[10] = '\0';
+    data[30] = '\0';
+
+    noodleMatch(data, data_len, "\0a", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(2U, ctxt.size());
+    ASSERT_EQ(11U, ctxt[0].to);
+    ASSERT_EQ(31U, ctxt[1].to);
+    ctxt.clear();
+}
+
+// Test: non-alphabetic characters with noCase flag
+TEST(Noodle, noodNonAlphaNoCase) {
+    const size_t data_len = 128;
+    u8 data[data_len];
+    memset(data, '1', data_len);
+
+    // noCase should have no effect on digits
+    noodleMatch(data, data_len, "1", 1, 1, hlmSimpleCallback);
+    ASSERT_EQ(128U, ctxt.size());
+    ctxt.clear();
+
+    // Non-alpha double
+    memset(data, '!', data_len);
+    data[0] = '#';
+    noodleMatch(data, data_len, "#!", 2, 1, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(1U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: case-insensitive double matching
+TEST(Noodle, noodDoubleCaseInsensitive) {
+    const size_t data_len = 128;
+    u8 data[data_len];
+
+    // "aB" pattern should match "ab", "aB", "Ab", "AB" with noCase
+    memset(data, 'x', data_len);
+    data[10] = 'a'; data[11] = 'b';
+    data[20] = 'A'; data[21] = 'B';
+    data[30] = 'a'; data[31] = 'B';
+    data[40] = 'A'; data[41] = 'b';
+
+    noodleMatch(data, data_len, "aB", 2, 1, hlmSimpleCallback);
+    ASSERT_EQ(4U, ctxt.size());
+    ASSERT_EQ(11U, ctxt[0].to);
+    ASSERT_EQ(21U, ctxt[1].to);
+    ASSERT_EQ(31U, ctxt[2].to);
+    ASSERT_EQ(41U, ctxt[3].to);
+    ctxt.clear();
+
+    // Without noCase, only exact match
+    noodleMatch(data, data_len, "aB", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(31U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: various buffer sizes to exercise scanLoop vs scanOnce paths
+// This is especially important for SVE where vector length varies
+TEST(Noodle, noodVariousSizesSingle) {
+    for (u32 len = 1; len <= 512; len++) {
+        std::vector<u8> data(len, 'z');
+
+        ctxt.clear();
+        noodleMatch(data.data(), len, "z", 1, 0, hlmSimpleCallback);
+        EXPECT_EQ(len, ctxt.size()) << "Failed at len=" << len;
+    }
+    ctxt.clear();
+}
+
+TEST(Noodle, noodVariousSizesDouble) {
+    for (u32 len = 2; len <= 512; len++) {
+        std::vector<u8> data(len, 'z');
+
+        ctxt.clear();
+        noodleMatch(data.data(), len, "zz", 2, 0, hlmSimpleCallback);
+        EXPECT_EQ(len - 1, ctxt.size()) << "Failed at len=" << len;
+    }
+    ctxt.clear();
+}
+
+// Test: match at the very end of buffer
+TEST(Noodle, noodMatchAtEnd) {
+    const size_t data_len = 128;
+    u8 data[data_len];
+    memset(data, 'x', data_len);
+    data[data_len - 1] = 'y';
+
+    noodleMatch(data, data_len, "y", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(data_len - 1, ctxt[0].to);
+    ctxt.clear();
+
+    // Double at end
+    data[data_len - 2] = 'a';
+    data[data_len - 1] = 'b';
+    noodleMatch(data, data_len, "ab", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(data_len - 1, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: match at the very beginning of buffer
+TEST(Noodle, noodMatchAtStart) {
+    const size_t data_len = 128;
+    u8 data[data_len];
+    memset(data, 'x', data_len);
+    data[0] = 'y';
+
+    noodleMatch(data, data_len, "y", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(0U, ctxt[0].to);
+    ctxt.clear();
+
+    // Double at start
+    data[0] = 'a';
+    data[1] = 'b';
+    noodleMatch(data, data_len, "ab", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(1U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: single match in the middle of a large buffer (exercises loop path)
+TEST(Noodle, noodSingleMatchLargeBuffer) {
+    const size_t data_len = 4096;
+    std::vector<u8> data(data_len, 'x');
+    data[2048] = 'y';
+
+    noodleMatch(data.data(), data_len, "y", 1, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(2048U, ctxt[0].to);
+    ctxt.clear();
+}
+
+TEST(Noodle, noodDoubleMatchLargeBuffer) {
+    const size_t data_len = 4096;
+    std::vector<u8> data(data_len, 'x');
+    data[2048] = 'a';
+    data[2049] = 'b';
+
+    noodleMatch(data.data(), data_len, "ab", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(1U, ctxt.size());
+    ASSERT_EQ(2049U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: alignment sweep for double scan with NUL byte
+// This is critical for the SVE scanDoubleOnce key1=='\0' logic
+TEST(Noodle, noodNulByteCutoverDouble) {
+    const size_t max_data_len = 256;
+    u8 data[max_data_len + 16];
+    memset(data, 'z', max_data_len + 16);
+
+    for (u32 align = 0; align < 16; align++) {
+        for (u32 len = 3; len < max_data_len; len++) {
+            // Place a "z\0" at position len-2
+            u8 *base = data + align;
+            memset(base, 'z', len);
+            base[len - 1] = '\0';
+
+            ctxt.clear();
+            noodleMatch(base, len, "z\0", 2, 0, hlmSimpleCallback);
+            EXPECT_EQ(1U, ctxt.size())
+                << "align=" << align << " len=" << len;
+            if (ctxt.size() == 1) {
+                EXPECT_EQ(len - 1, ctxt[0].to)
+                    << "align=" << align << " len=" << len;
+            }
+
+            // Restore
+            base[len - 1] = 'z';
+        }
+    }
+    ctxt.clear();
+}
+
+// Test: streaming mode with double match spanning history and current buffer
+TEST(Noodle, noodStreamingDouble) {
+    u32 id = 1000;
+    hwlmLiteral lit(std::string("ab", 2), false, id);
+    auto n = noodBuildTable(lit);
+    ASSERT_TRUE(static_cast<bool>(n));
+
+    // 'a' at end of history, 'b' at start of current
+    u8 hbuf[4] = {'x', 'x', 'x', 'a'};
+    u8 buf[4] = {'b', 'x', 'x', 'x'};
+
+    struct hs_scratch scratch;
+    hwlm_error_t rv = noodExecStreaming(n.get(), hbuf, 4, buf, 4,
+                                        hlmSimpleCallback, &scratch);
+    ASSERT_EQ(HWLM_SUCCESS, rv);
+    ASSERT_EQ(1U, ctxt.size());
+    // The match end position should be 0 (first byte of buf)
+    ASSERT_EQ(0U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: streaming mode - no match across boundary
+TEST(Noodle, noodStreamingNoMatch) {
+    u32 id = 1000;
+    hwlmLiteral lit(std::string("ab", 2), false, id);
+    auto n = noodBuildTable(lit);
+    ASSERT_TRUE(static_cast<bool>(n));
+
+    u8 hbuf[4] = {'x', 'x', 'x', 'x'};
+    u8 buf[4] = {'x', 'x', 'x', 'x'};
+
+    struct hs_scratch scratch;
+    hwlm_error_t rv = noodExecStreaming(n.get(), hbuf, 4, buf, 4,
+                                        hlmSimpleCallback, &scratch);
+    ASSERT_EQ(HWLM_SUCCESS, rv);
+    ASSERT_EQ(0U, ctxt.size());
+    ctxt.clear();
+}
+
+// Test: offset parameter to start scanning from a later position
+TEST(Noodle, noodWithOffset) {
+    const size_t data_len = 128;
+    u8 data[data_len];
+    memset(data, 'a', data_len);
+
+    // Start scanning from offset 64
+    u32 id = 1000;
+    hwlmLiteral lit(std::string("a", 1), false, id);
+    auto n = noodBuildTable(lit);
+    ASSERT_TRUE(static_cast<bool>(n));
+
+    struct hs_scratch scratch;
+    hwlm_error_t rv = noodExec(n.get(), data, data_len, 64,
+                               hlmSimpleCallback, &scratch);
+    ASSERT_EQ(HWLM_SUCCESS, rv);
+    ASSERT_EQ(64U, ctxt.size());
+    ASSERT_EQ(64U, ctxt[0].to);
+    ctxt.clear();
+}
+
+// Test: long pattern (4+ chars) with double scan path
+TEST(Noodle, noodLongPatternCutover) {
+    const size_t max_data_len = 256;
+    u8 data[max_data_len + 16];
+    memset(data, 'a', max_data_len + 16);
+
+    for (u32 align = 0; align < 16; align++) {
+        for (u32 len = 4; len < max_data_len; len++) {
+            ctxt.clear();
+            noodleMatch(data + align, len, "aaaa", 4, 0, hlmSimpleCallback);
+            EXPECT_EQ(len - 3, ctxt.size())
+                << "align=" << align << " len=" << len;
+        }
+    }
+    ctxt.clear();
+}
+
+// Test: repeated pattern with multiple matches in single vector
+TEST(Noodle, noodDenseMatches) {
+    // Alternating 'ab' pattern creates dense double matches
+    const size_t data_len = 256;
+    u8 data[data_len];
+    for (size_t i = 0; i < data_len; i++) {
+        data[i] = (i % 2 == 0) ? 'a' : 'b';
+    }
+
+    noodleMatch(data, data_len, "ab", 2, 0, hlmSimpleCallback);
+    ASSERT_EQ(128U, ctxt.size());
+    for (u32 i = 0; i < 128; i++) {
+        ASSERT_EQ(i * 2 + 1, ctxt[i].to);
+    }
+    ctxt.clear();
+}
+
index d475308fce19ab9def91370d5cd31abfb9cb9190..ca47f057c0b7e6004d2c0e844a70291aa1f9a6c0 100644 (file)
@@ -121,14 +121,14 @@ static void init_raw_dfa16(struct ue2::raw_dfa *dfa, const ReportID rID)
     dfa->alpha_remap['f'] = 6;
     dfa->alpha_remap[256] = 7; /* for some reason there's a check that run on dfa->alpha_size-1 */
 
-                        /* a b c d e o f */
-    dfa->states[0].next = {0,0,0,0,0,0,0};
-    dfa->states[1].next = {2,2,1,1,1,1,1};      /* nothing */
-    dfa->states[2].next = {2,2,3,3,3,1,1};      /* [a,b] */
-    dfa->states[3].next = {2,2,4,4,4,1,1};      /* [a,b][c-e]{1} */
-    dfa->states[4].next = {2,2,5,5,5,1,1};      /* [a,b][c-e]{2} */
-    fill_straight_regex_sequence(dfa, 5, 7, 7); /* [a,b][c-e]{3}o */
-    dfa->states[7].next = {2,2,1,1,1,1,1};      /* [a,b][c-e]{3}of */
+                        /* a b c d e o f */
+    dfa->states[0].next = {0,0,0,0,0,0,0,0};
+    dfa->states[1].next = {2,2,1,1,1,1,1,1};      /* nothing */
+    dfa->states[2].next = {2,2,3,3,3,1,1,1};      /* [a,b] */
+    dfa->states[3].next = {2,2,4,4,4,1,1,1};      /* [a,b][c-e]{1} */
+    dfa->states[4].next = {2,2,5,5,5,1,1,1};      /* [a,b][c-e]{2} */
+    fill_straight_regex_sequence(dfa, 5, 7, 8); /* [a,b][c-e]{3}o */
+    dfa->states[7].next = {2,2,1,1,1,1,1,1};      /* [a,b][c-e]{3}of */
 }
 
 #if defined(HAVE_AVX512VBMI) || defined(HAVE_SVE)
@@ -176,14 +176,14 @@ static void init_raw_dfa32(struct ue2::raw_dfa *dfa, const ReportID rID)
     }
     dfa->alpha_remap[256] = 17; /* for some reason there's a check that run on dfa->alpha_size-1 */
 
-                         /* a b c d e o f 0 1 2 3 4 5 6 7 8 9 */
-    dfa->states[0].next  = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
-    dfa->states[1].next  = {2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* nothing */
-    dfa->states[2].next  = {2,2,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b] */
-    dfa->states[3].next  = {2,2,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{1} */
-    dfa->states[4].next  = {2,2,5,5,5,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{2} */
-    fill_straight_regex_sequence(dfa, 5, 17, 17);                /* [a,b][c-e]{3}of012345678 */
-    dfa->states[17].next = {2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{3}of0123456789 */
+                         /* a b c d e o f 0 1 2 3 4 5 6 7 8 9 */
+    dfa->states[0].next  = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
+    dfa->states[1].next  = {2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* nothing */
+    dfa->states[2].next  = {2,2,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b] */
+    dfa->states[3].next  = {2,2,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{1} */
+    dfa->states[4].next  = {2,2,5,5,5,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{2} */
+    fill_straight_regex_sequence(dfa, 5, 17, 18);                /* [a,b][c-e]{3}of012345678 */
+    dfa->states[17].next = {2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1};  /* [a,b][c-e]{3}of0123456789 */
 }
 #endif /* defined(HAVE_AVX512VBMI) || defined(HAVE_SVE) */
 
diff --git a/unit/internal/vermicelli_extra.cpp b/unit/internal/vermicelli_extra.cpp
new file mode 100644 (file)
index 0000000..2cdc1c2
--- /dev/null
@@ -0,0 +1,751 @@
+/*
+ * Copyright (c) 2026, AhnLab, Inc.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ *  * Redistributions of source code must retain the above copyright notice,
+ *    this list of conditions and the following disclaimer.
+ *  * Redistributions in binary form must reproduce the above copyright
+ *    notice, this list of conditions and the following disclaimer in the
+ *    documentation and/or other materials provided with the distribution.
+ *  * Neither the name of Intel Corporation nor the names of its contributors
+ *    may be used to endorse or promote products derived from this software
+ *    without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+
+/**
+ * Additional unit tests for vermicelli accelerator functions.
+ *
+ * Focuses on:
+ * - Small buffer edge cases (below VECTORSIZE)
+ * - Exact VECTORSIZE boundary
+ * - Single-byte buffers
+ * - Per-position match verification
+ * - Partial match semantics for double vermicelli
+ * - Missing reverse double NoMatch tests
+ * - Non-alphabetic characters
+ */
+
+#include "config.h"
+
+#include "gtest/gtest.h"
+#include "nfa/vermicelli.hpp"
+#include "util/bitutils.h"
+
+#include <cstring>
+#include <string>
+#include <vector>
+#include <algorithm>
+
+// ============================================================================
+// Forward single-byte vermicelli: small buffer tests
+// ============================================================================
+
+TEST(VermicelliExtra, SmallMatchSingleByte) {
+    // Buffer of size 1
+    const u8 buf[1] = {'x'};
+    const u8 *rv = vermicelliExec('x', 0, buf, buf + 1);
+    EXPECT_EQ(buf, rv);
+}
+
+TEST(VermicelliExtra, SmallNoMatchSingleByte) {
+    const u8 buf[1] = {'y'};
+    const u8 *rv = vermicelliExec('x', 0, buf, buf + 1);
+    EXPECT_EQ(buf + 1, rv);
+}
+
+TEST(VermicelliExtra, SmallMatchTwoBytes) {
+    // Match at position 0
+    const u8 buf1[2] = {'x', 'y'};
+    EXPECT_EQ(buf1, vermicelliExec('x', 0, buf1, buf1 + 2));
+    // Match at position 1
+    const u8 buf2[2] = {'y', 'x'};
+    EXPECT_EQ(buf2 + 1, vermicelliExec('x', 0, buf2, buf2 + 2));
+}
+
+TEST(VermicelliExtra, SmallNoCaseMatch) {
+    // Searching uppercase with nocase in a 3-byte buffer containing lowercase
+    const u8 buf[3] = {'z', 'a', 'z'};
+    const u8 *rv = vermicelliExec('A', 1, buf, buf + 3);
+    EXPECT_EQ(buf + 1, rv);
+}
+
+TEST(VermicelliExtra, SmallRangeSweep) {
+    // For each buffer size from 1 to VECTORSIZE-1, place the target at
+    // every position and verify it is found correctly.
+    for (int len = 1; len < 32; len++) {
+        SCOPED_TRACE(len);
+        std::vector<u8> buf(len, 'b');
+        for (int pos = 0; pos < len; pos++) {
+            SCOPED_TRACE(pos);
+            buf[pos] = 'a';
+            const u8 *rv = vermicelliExec('a', 0, buf.data(),
+                                          buf.data() + len);
+            EXPECT_EQ(buf.data() + pos, rv);
+            buf[pos] = 'b';  // cppcheck-suppress unreadVariable // restore
+        }
+    }
+}
+
+// ============================================================================
+// Exact VECTORSIZE boundary tests
+// ============================================================================
+
+TEST(VermicelliExtra, ExactVecSizeNoMatch) {
+    // Buffer of exactly 32 bytes with no match
+    std::vector<u8> buf(32, 'b');
+    const u8 *rv = vermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data() + 32, rv);
+}
+
+TEST(VermicelliExtra, ExactVecSizeMatchFirst) {
+    std::vector<u8> buf(32, 'b');
+    buf[0] = 'a';
+    const u8 *rv = vermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data(), rv);
+}
+
+TEST(VermicelliExtra, ExactVecSizeMatchLast) {
+    std::vector<u8> buf(32, 'b');
+    buf[31] = 'a';
+    const u8 *rv = vermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data() + 31, rv);
+}
+
+TEST(VermicelliExtra, ExactVecSizePlusOne) {
+    // VECTORSIZE + 1 bytes, match at last position (exercises tail path)
+    std::vector<u8> buf(33, 'b');
+    buf[32] = 'a';
+    const u8 *rv = vermicelliExec('a', 0, buf.data(), buf.data() + 33);
+    EXPECT_EQ(buf.data() + 32, rv);
+}
+
+// ============================================================================
+// Per-position match within SIMD register
+// ============================================================================
+
+TEST(VermicelliExtra, PerPositionForward) {
+    // Large buffer, sweep match through every position
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'b');
+        buf[pos] = 'a';
+        const u8 *rv = vermicelliExec('a', 0, buf.data(),
+                                      buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);
+    }
+}
+
+TEST(VermicelliExtra, PerPositionReverse) {
+    // Large buffer, sweep match through every position for reverse search
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'b');
+        buf[pos] = 'a';
+        const u8 *rv = rvermicelliExec('a', 0, buf.data(),
+                                       buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);
+    }
+}
+
+// ============================================================================
+// Non-alphabetic character tests
+// ============================================================================
+
+TEST(VermicelliExtra, NonAlphaChars) {
+    //                01234567890123456
+    const u8 buf[] = "hello, world! 123";
+    size_t len = strlen(reinterpret_cast<const char *>(buf));
+
+    // Space character at position 6
+    const u8 *rv = vermicelliExec(' ', 0, buf, buf + len);
+    EXPECT_EQ(buf + 6, rv);
+
+    // Digit '1' at position 14
+    rv = vermicelliExec('1', 0, buf, buf + len);
+    EXPECT_EQ(buf + 14, rv);
+
+    // Exclamation mark at position 12
+    rv = vermicelliExec('!', 0, buf, buf + len);
+    EXPECT_EQ(buf + 12, rv);
+
+    // Comma at position 5
+    rv = vermicelliExec(',', 0, buf, buf + len);
+    EXPECT_EQ(buf + 5, rv);
+}
+
+// ============================================================================
+// NVermicelli (negated) small buffer tests
+// ============================================================================
+
+TEST(NVermicelliExtra, SmallSingleByte) {
+    // 1-byte buffer, byte matches → buf_end
+    const u8 buf1[1] = {'a'};
+    EXPECT_EQ(buf1 + 1, nvermicelliExec('a', 0, buf1, buf1 + 1));
+
+    // 1-byte buffer, byte doesn't match → position 0
+    const u8 buf2[1] = {'b'};
+    EXPECT_EQ(buf2, nvermicelliExec('a', 0, buf2, buf2 + 1));
+}
+
+TEST(NVermicelliExtra, SmallRangeSweep) {
+    // Buffer of matching chars with one different char at each position
+    for (int len = 1; len < 32; len++) {
+        SCOPED_TRACE(len);
+        std::vector<u8> buf(len, 'a');
+        for (int pos = 0; pos < len; pos++) {
+            SCOPED_TRACE(pos);
+            buf[pos] = 'z';
+            const u8 *rv = nvermicelliExec('a', 0, buf.data(),
+                                           buf.data() + len);
+            EXPECT_EQ(buf.data() + pos, rv);
+            buf[pos] = 'a';  // cppcheck-suppress unreadVariable
+        }
+    }
+}
+
+TEST(NVermicelliExtra, NoCaseSmall) {
+    // nocase: both 'a' and 'A' should be considered 'the char'
+    const u8 buf[4] = {'a', 'A', 'a', 'b'};
+    const u8 *rv = nvermicelliExec('A', 1, buf, buf + 4);
+    EXPECT_EQ(buf + 3, rv);  // 'b' is the first non-matching
+}
+
+// ============================================================================
+// Reverse vermicelli small buffer tests
+// ============================================================================
+
+TEST(RVermicelliExtra, SmallSingleByte) {
+    const u8 buf[1] = {'x'};
+    EXPECT_EQ(buf, rvermicelliExec('x', 0, buf, buf + 1));
+
+    const u8 raw2[2] = {0, 'y'};
+    const u8 *buf2 = raw2 + 1;
+    EXPECT_EQ(raw2, rvermicelliExec('x', 0, buf2, buf2 + 1));
+}
+
+TEST(RVermicelliExtra, SmallThreeBytes) {
+    const u8 buf[3] = {'a', 'b', 'a'};
+    // Reverse should find the LAST 'a' at position 2
+    const u8 *rv = rvermicelliExec('a', 0, buf, buf + 3);
+    EXPECT_EQ(buf + 2, rv);
+
+    // Reverse find last 'b' at position 1
+    rv = rvermicelliExec('b', 0, buf, buf + 3);
+    EXPECT_EQ(buf + 1, rv);
+}
+
+TEST(RVermicelliExtra, SmallRangeSweep) {
+    for (int len = 1; len < 32; len++) {
+        SCOPED_TRACE(len);
+        std::vector<u8> buf(len, 'b');
+        for (int pos = 0; pos < len; pos++) {
+            SCOPED_TRACE(pos);
+            buf[pos] = 'a';
+            const u8 *rv = rvermicelliExec('a', 0, buf.data(),
+                                           buf.data() + len);
+            // Reverse returns the LAST match; 'a' is the only one at 'pos'
+            EXPECT_EQ(buf.data() + pos, rv);
+            buf[pos] = 'b';  // cppcheck-suppress unreadVariable
+        }
+    }
+}
+
+// ============================================================================
+// Reverse negated vermicelli small buffer tests
+// ============================================================================
+
+TEST(RNVermicelliExtra, SmallSingleByte) {
+    const u8 raw[2] = {0, 'a'};
+    const u8 *buf = raw + 1;
+    // All match → not found → raw (i.e. buf - 1)
+    EXPECT_EQ(raw, rnvermicelliExec('a', 0, buf, buf + 1));
+
+    const u8 buf2[1] = {'b'};
+    // Doesn't match → found at 0
+    EXPECT_EQ(buf2, rnvermicelliExec('a', 0, buf2, buf2 + 1));
+}
+
+TEST(RNVermicelliExtra, SmallRangeSweep) {
+    for (int len = 1; len < 32; len++) {
+        SCOPED_TRACE(len);
+        std::vector<u8> buf(len, 'a');
+        for (int pos = 0; pos < len; pos++) {
+            SCOPED_TRACE(pos);
+            buf[pos] = 'z';
+            const u8 *rv = rnvermicelliExec('a', 0, buf.data(),
+                                            buf.data() + len);
+            // Reverse negated finds the LAST non-matching byte
+            EXPECT_EQ(buf.data() + pos, rv);
+            buf[pos] = 'a';  // cppcheck-suppress unreadVariable
+        }
+    }
+}
+
+// ============================================================================
+// Double vermicelli: small buffer / edge case tests
+// ============================================================================
+
+TEST(DoubleVermicelliExtra, TwoByteBuffer) {
+    const u8 buf[2] = {'a', 'b'};
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf, buf + 2);
+    EXPECT_EQ(buf, rv);
+
+    // No match
+    rv = vermicelliDoubleExec('b', 'a', 0, buf, buf + 2);
+    // 'b' at end → partial match
+    EXPECT_EQ(buf + 1, rv);  // last byte matches c1
+}
+
+TEST(DoubleVermicelliExtra, TwoByteNoFullMatch) {
+    const u8 buf[2] = {'x', 'y'};
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf, buf + 2);
+    EXPECT_EQ(buf + 2, rv);
+}
+
+TEST(DoubleVermicelliExtra, ThreeByteMatchMiddle) {
+    const u8 buf[3] = {'x', 'a', 'b'};
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf, buf + 3);
+    EXPECT_EQ(buf + 1, rv);
+}
+
+TEST(DoubleVermicelliExtra, SmallRangeSweep) {
+    // Sweep a double match through small buffers
+    for (int len = 2; len < 32; len++) {
+        SCOPED_TRACE(len);
+        for (int pos = 0; pos < len - 1; pos++) {
+            SCOPED_TRACE(pos);
+            std::vector<u8> buf(len, 'z');
+            buf[pos] = 'a';
+            buf[pos + 1] = 'b';
+            const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                                buf.data() + len);
+            EXPECT_EQ(buf.data() + pos, rv);
+        }
+    }
+}
+
+TEST(DoubleVermicelliExtra, PartialMatchAtEnd) {
+    // The last byte matches c1 but there is no c2 after it
+    const u8 buf[] = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxa";
+    size_t len = strlen(reinterpret_cast<const char *>(buf));
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf, buf + len);
+    EXPECT_EQ(buf + len - 1, rv);  // Partial match at the very end
+}
+
+TEST(DoubleVermicelliExtra, NoCaseSmall) {
+    // nocase with small buffer
+    const u8 buf[3] = {'x', 'A', 'B'};
+    const u8 *rv = vermicelliDoubleExec('A', 'B', 1, buf, buf + 3);
+    EXPECT_EQ(buf + 1, rv);
+
+    const u8 buf2[3] = {'x', 'a', 'b'};
+    rv = vermicelliDoubleExec('A', 'B', 1, buf2, buf2 + 3);
+    EXPECT_EQ(buf2 + 1, rv);
+}
+
+TEST(DoubleVermicelliExtra, ExactVecSize) {
+    std::vector<u8> buf(32, 'z');
+    buf[30] = 'a';
+    buf[31] = 'b';
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                        buf.data() + 32);
+    EXPECT_EQ(buf.data() + 30, rv);
+}
+
+TEST(DoubleVermicelliExtra, ExactVecSizePlusOne) {
+    // Match at the very end, in the tail path
+    std::vector<u8> buf(33, 'z');
+    buf[31] = 'a';
+    buf[32] = 'b';
+    const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                        buf.data() + 33);
+    EXPECT_EQ(buf.data() + 31, rv);
+}
+
+// ============================================================================
+// Reverse double vermicelli: NoMatch tests (were missing)
+// ============================================================================
+
+TEST(RDoubleVermicelliExtra, ExecNoMatch1) {
+    char t0[] = " bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+    char *t1 = t0 + 1;
+
+    for (size_t i = 0; i < 16; i++) {
+        for (size_t j = 0; j < 16; j++) {
+            SCOPED_TRACE(i);
+            SCOPED_TRACE(j);
+            const u8 *begin = reinterpret_cast<const u8 *>(t1) + i;
+            const u8 *end = reinterpret_cast<const u8 *>(t1) + strlen(t1) - j;
+            if (begin >= end) continue;
+
+            const u8 *before_begin = begin - 1;
+            const u8 *rv = rvermicelliDoubleExec('a', 'b', 0, begin, end);
+            ASSERT_EQ(before_begin, rv);
+
+            rv = rvermicelliDoubleExec('B', 'B', 0, begin, end);
+            ASSERT_EQ(before_begin, rv);
+
+            rv = rvermicelliDoubleExec('A', 'B', 1, begin, end);
+            ASSERT_EQ(before_begin, rv);
+        }
+    }
+}
+
+// ============================================================================
+// Reverse double vermicelli: small buffer tests
+// ============================================================================
+
+TEST(RDoubleVermicelliExtra, TwoByteBuffer) {
+    const u8 buf[2] = {'a', 'b'};
+    // rvermicelliDoubleExec returns position of c2
+    const u8 *rv = rvermicelliDoubleExec('a', 'b', 0, buf, buf + 2);
+    EXPECT_EQ(buf + 1, rv);
+
+    // No match in reverse for 'b','a'
+    const u8 raw2[3] = {0, 'a', 'b'};
+    const u8 *buf2 = raw2 + 1;
+    rv = rvermicelliDoubleExec('b', 'a', 0, buf2, buf2 + 2);
+    EXPECT_EQ(raw2, rv);
+}
+
+TEST(RDoubleVermicelliExtra, SmallRangeSweep) {
+    for (int len = 2; len < 32; len++) {
+        SCOPED_TRACE(len);
+        for (int pos = 0; pos < len - 1; pos++) {
+            SCOPED_TRACE(pos);
+            std::vector<u8> buf(len, 'z');
+            buf[pos] = 'a';
+            buf[pos + 1] = 'b';
+            // Only match at (pos, pos+1), reverse finds it
+            const u8 *rv = rvermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                                 buf.data() + len);
+            EXPECT_EQ(buf.data() + pos + 1, rv);  // returns c2 position
+        }
+    }
+}
+
+TEST(RDoubleVermicelliExtra, MultipleMatchesReverse) {
+    // Multiple double matches, reverse should find the last one
+    //                0123456789
+    const u8 buf[] = "xxaBxxaBxx";
+    size_t len = 10;
+    const u8 *rv = rvermicelliDoubleExec('a', 'B', 0, buf, buf + len);
+    EXPECT_EQ(buf + 7, rv);  // Last 'B' (c2) in 'aB' pair at pos 6,7
+}
+
+// ============================================================================
+// Double vermicelli masked: small buffer tests
+// ============================================================================
+#ifndef HAVE_SVE2
+// vermicelliDoubleMaskedExec() for small buffer is not implemented with SVE
+TEST(DoubleVermicelliMaskedExtra, TwoByteBuffer) {
+    const u8 buf[2] = {'a', 'b'};
+    const u8 *rv = vermicelliDoubleMaskedExec('a', 'b', 0xff, 0xff,
+                                              buf, buf + 2);
+    EXPECT_EQ(buf, rv);
+}
+
+TEST(DoubleVermicelliMaskedExtra, SmallRangeSweep) {
+    for (int len = 2; len < 32; len++) {
+        SCOPED_TRACE(len);
+        for (int pos = 0; pos < len - 1; pos++) {
+            SCOPED_TRACE(pos);
+            std::vector<u8> buf(len, 'z');
+            buf[pos] = 'a';
+            buf[pos + 1] = 'b';
+            const u8 *rv = vermicelliDoubleMaskedExec('a', 'b', 0xff, 0xff,
+                                                      buf.data(),
+                                                      buf.data() + len);
+            EXPECT_EQ(buf.data() + pos, rv);
+        }
+    }
+}
+
+TEST(DoubleVermicelliMaskedExtra, MaskPartialBits) {
+    // Use masks to match only certain bits
+    // 'a' = 0x61, 'b' = 0x62, 'c' = 0x63
+    // With mask 0xfe, 'b' (0x62) and 'c' (0x63) both become 0x62
+    const u8 buf[3] = {'a', 'c', 'z'};
+    const u8 *rv = vermicelliDoubleMaskedExec('a', 'b', 0xff, 0xfe,
+                                              buf, buf + 3);
+    // 'a' & 0xff = 'a' matches c1='a', 'c' & 0xfe = 0x62 matches c2='b'=0x62
+    EXPECT_EQ(buf, rv);
+}
+#endif
+
+// ============================================================================
+// Alignment stress test: varying start offset and length
+// ============================================================================
+
+TEST(VermicelliExtra, AlignmentStress) {
+    // Allocate a large buffer and test with various alignments
+    const size_t TOTAL = 256;
+    std::vector<u8> alloc(TOTAL + 64, 'b');
+    u8 *base = alloc.data();
+
+    // Ensure base is aligned to 64 (safe source)
+    u8 *aligned_base = reinterpret_cast<u8 *>(
+        (reinterpret_cast<uintptr_t>(base) + 63) & ~uintptr_t(63));
+
+    for (size_t offset = 0; offset < 32; offset++) {
+        for (size_t len = 1; len <= 128; len++) {
+            SCOPED_TRACE(offset);
+            SCOPED_TRACE(len);
+
+            u8 *buf = aligned_base + offset;
+            std::fill(buf, buf + len, 'b');
+
+            // No match
+            const u8 *rv = vermicelliExec('a', 0, buf, buf + len);
+            EXPECT_EQ(buf + len, rv);
+
+            // Place match at last position
+            buf[len - 1] = 'a';
+            rv = vermicelliExec('a', 0, buf, buf + len);
+            EXPECT_EQ(buf + len - 1, rv);
+            buf[len - 1] = 'b';
+
+            // Place match at first position
+            buf[0] = 'a';
+            rv = vermicelliExec('a', 0, buf, buf + len);
+            EXPECT_EQ(buf, rv);
+            buf[0] = 'b';
+        }
+    }
+}
+
+// ============================================================================
+// NVermicelli: exact VECTORSIZE boundary
+// ============================================================================
+
+TEST(NVermicelliExtra, ExactVecSizeAllMatch) {
+    std::vector<u8> buf(32, 'a');
+    const u8 *rv = nvermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data() + 32, rv);
+}
+
+TEST(NVermicelliExtra, ExactVecSizeFirstDiff) {
+    std::vector<u8> buf(32, 'a');
+    buf[0] = 'x';
+    const u8 *rv = nvermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data(), rv);
+}
+
+TEST(NVermicelliExtra, ExactVecSizeLastDiff) {
+    std::vector<u8> buf(32, 'a');
+    buf[31] = 'x';
+    const u8 *rv = nvermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data() + 31, rv);
+}
+
+// ============================================================================
+// Reverse vermicelli: exact VECTORSIZE boundary
+// ============================================================================
+
+TEST(RVermicelliExtra, ExactVecSizeNoMatch) {
+    std::vector<u8> buf(33, 'b');
+    const u8 *data = buf.data() + 1;
+    const u8 *rv = rvermicelliExec('a', 0, data, data + 32);
+    EXPECT_EQ(buf.data(), rv);
+}
+
+TEST(RVermicelliExtra, ExactVecSizeMatchFirst) {
+    std::vector<u8> buf(32, 'b');
+    buf[0] = 'a';
+    const u8 *rv = rvermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data(), rv);
+}
+
+TEST(RVermicelliExtra, ExactVecSizeMatchLast) {
+    std::vector<u8> buf(32, 'b');
+    buf[31] = 'a';
+    const u8 *rv = rvermicelliExec('a', 0, buf.data(), buf.data() + 32);
+    EXPECT_EQ(buf.data() + 31, rv);
+}
+
+// ============================================================================
+// Double vermicelli: per-position sweep in large buffer
+// ============================================================================
+
+TEST(DoubleVermicelliExtra, PerPositionSweep) {
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN - 1; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'z');
+        buf[pos] = 'a';
+        buf[pos + 1] = 'b';
+        const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                            buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);
+    }
+}
+
+TEST(RDoubleVermicelliExtra, PerPositionSweep) {
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN - 1; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'z');
+        buf[pos] = 'a';
+        buf[pos + 1] = 'b';
+        const u8 *rv = rvermicelliDoubleExec('a', 'b', 0, buf.data(),
+                                             buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos + 1, rv);  // returns c2 pos
+    }
+}
+
+// ============================================================================
+// NVermicelli: per-position sweep
+// ============================================================================
+
+TEST(NVermicelliExtra, PerPositionSweep) {
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'a');
+        buf[pos] = 'x';
+        const u8 *rv = nvermicelliExec('a', 0, buf.data(),
+                                       buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);
+    }
+}
+
+// ============================================================================
+// RNVermicelli: per-position sweep
+// ============================================================================
+
+TEST(RNVermicelliExtra, PerPositionSweep) {
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'a');
+        buf[pos] = 'x';
+        const u8 *rv = rnvermicelliExec('a', 0, buf.data(),
+                                        buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);  // reverse: finds last non-match
+    }
+}
+
+// ============================================================================
+// Double vermicelli masked: per-position sweep
+// ============================================================================
+
+TEST(DoubleVermicelliMaskedExtra, PerPositionSweep) {
+    const size_t LEN = 128;
+    for (size_t pos = 0; pos < LEN - 1; pos++) {
+        SCOPED_TRACE(pos);
+        std::vector<u8> buf(LEN, 'z');
+        buf[pos] = 'a';
+        buf[pos + 1] = 'b';
+        const u8 *rv = vermicelliDoubleMaskedExec('a', 'b', 0xff, 0xff,
+                                                  buf.data(),
+                                                  buf.data() + LEN);
+        EXPECT_EQ(buf.data() + pos, rv);
+    }
+}
+
+// ============================================================================
+// Cross-boundary double match stress test
+// ============================================================================
+
+TEST(DoubleVermicelliExtra, CrossBoundarySweep) {
+    // Place a double match right at every potential SIMD boundary
+    // VECTORSIZE = 32 in this build
+    const size_t LEN = 256;
+    for (size_t start = 0; start < 32; start++) {
+        SCOPED_TRACE(start);
+        std::vector<u8> alloc(LEN + 64, 'z');
+        u8 *buf = alloc.data() + start;
+        size_t buflen = LEN;
+
+        for (size_t pos = 0; pos < buflen - 1; pos++) {
+            buf[pos] = 'a';
+            buf[pos + 1] = 'b';
+
+            const u8 *rv = vermicelliDoubleExec('a', 'b', 0, buf,
+                                                buf + buflen);
+            EXPECT_EQ(buf + pos, rv) << "start=" << start << " pos=" << pos;
+
+            buf[pos] = 'z';
+            buf[pos + 1] = 'z';
+        }
+    }
+}
+
+// ============================================================================
+// Consistency test: forward + reverse on same data
+// ============================================================================
+
+TEST(VermicelliExtra, ForwardReverseConsistency) {
+    const size_t LEN = 200;
+    std::vector<u8> buf(LEN, 'b');
+
+    // Place a single 'a' at each position
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        buf[pos] = 'a';
+
+        const u8 *fwd = vermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+        const u8 *rev = rvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+
+        // With only one match, forward and reverse should find the same
+        EXPECT_EQ(fwd, rev);
+        EXPECT_EQ(buf.data() + pos, fwd);
+
+        buf[pos] = 'b';
+    }
+
+    // Two matches: forward finds first, reverse finds last
+    buf[10] = 'a';
+    buf[190] = 'a';
+    const u8 *fwd = vermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+    const u8 *rev = rvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+    EXPECT_EQ(buf.data() + 10, fwd);
+    EXPECT_EQ(buf.data() + 190, rev);
+}
+
+// ============================================================================
+// NVermicelli + RNVermicelli consistency
+// ============================================================================
+
+TEST(NVermicelliExtra, ForwardReverseConsistency) {
+    const size_t LEN = 200;
+    std::vector<u8> buf(LEN, 'a');
+
+    // Place a single different byte at each position
+    for (size_t pos = 0; pos < LEN; pos++) {
+        SCOPED_TRACE(pos);
+        buf[pos] = 'x';
+
+        const u8 *fwd = nvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+        const u8 *rev = rnvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+
+        EXPECT_EQ(fwd, rev);
+        EXPECT_EQ(buf.data() + pos, fwd);
+
+        buf[pos] = 'a';
+    }
+
+    // Two different bytes
+    buf[10] = 'x';
+    buf[190] = 'x';
+    const u8 *fwd = nvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+    const u8 *rev = rnvermicelliExec('a', 0, buf.data(), buf.data() + LEN);
+    EXPECT_EQ(buf.data() + 10, fwd);
+    EXPECT_EQ(buf.data() + 190, rev);
+}