Skip to content

Commit 8d6e030

Browse files
bobzhangclaude
andcommitted
perf(builtin): guaranteed-linear KMP fallback for substring search
The SIMD two-anchor find/rev_find already cut over to a fallback after enough dense false-anchor candidates, but that fallback was a naive scalar scan — still O(target * pattern) on adversarial inputs where both anchors and long pattern prefixes recur throughout the target (e.g. needle 'a'*32 + 'Z' + 'a'*31 over all-'a' text). This replaces the naive fallback with KMP, making find/rev_find O(target + pattern) in the worst case: the existing cutover budget trips on the 65th failed verification (`failures > 64`), capping pre-fallback verification at O(pattern) work, and the fallback itself is linear. The failure-table allocation is paid only after cutover, i.e. exclusively on pathological inputs. Resolves the standing TODO on find/rev_find ("consider using Two-Way algorithm to ensure linear time complexity") — KMP behind the existing budget gives the same guarantee with less machinery. The reverse fallback runs forward KMP over the prefix that can still contain a hit and keeps the rightmost match, continuing through overlaps via the failure table. The idea comes from PR #3786 (mizchi), whose dense-case analysis and verified-fallback design predate the SIMD two-anchor work that superseded its scanner; this lands its remaining piece. Benchmarks (moon bench --release, main -> this branch): adversary find m=64 n=4096: native 48.6 -> 6.1us (8.0x), js 453 -> 27.3us (16.6x), wasm-gc 112 -> 10.6us (10.6x) adversary rev_find m=64 n=4096: native 48.8 -> 7.0us, js 456 -> 27.4us, wasm-gc 112 -> 12.4us scaling adversary m=512 n=65536: native 7.20ms -> 91us (79x), js 56.2ms -> 433us (130x), wasm-gc 15.0ms -> 175us (86x) — the O(n*m) -> O(n+m) signature fast paths (dense miss, rare hit): par on all backends; the SIMD two-anchor path is untouched. Tests: whitebox KMP tests over periodic/overlapping patterns and boundary starts; blackbox end-to-end tests built from decoy blocks that each contribute exactly one false-anchor candidate, so both directions provably exhaust the 65-failure budget before the interesting region — dense-anchor misses, hits beyond the cutover point (including a first occurrence that straddles the decoy/hits boundary), reverse cutover resolving overlapping hits to the rightmost start, and forward/reverse naive-reference cross-checks over fast-path and fallback configurations. string_find_adversary_bench_test.mbt commits the adversarial workloads so the numbers above are reproducible. Mutation-verified: zeroing the failure table fails 2 tests; dropping the overlap continuation in the reverse fallback fails 2. Codex CLI review (ultra, round 1): production implementation verified sound by exhaustive binary-alphabet modeling (267M helper-contract cases, 89M full-route cases, no counterexample vs naive search), including the handoff off-by-ones and the skipped-position reasoning; three P2 findings all in tests/prose — a whitebox test argument that violated the reverse helper's bound (out-of-bounds unsafe_get, fixed to 7 and the precondition documented), an overlap test that never reached the fallback (rebuilt with decoy blocks), and overstated naive-reference coverage (rebuilt with both directions and both paths). Copilot review addressed: the cutover comments now describe BOTH triggers — the early ratio check (failures > 4 + scanned/8, tripping as soon as the 5th failure for candidates packed near the scan start) and the hard cap (failures > 64) — and the fallback-test header notes its spaced decoys deterministically force the hard-cap path. QuickCheck added (string_find_quickcheck_test.mbt): a general random-text/needle equivalence property against the naive references (dense two-symbol texts randomly exercise the early ratio cutover), and a fallback-forcing property that surrounds a randomized hit/decoy mixture with 70 corrupted blocks per side (anchors intact, one interior char flipped), fuzzing the KMP fallback itself with randomized needle shapes. Zeroing the failure table is falsified by the property after 2 cases. Round 2 (ultra) verified the fixes: "All three P2 fixes are correct and complete. Whitebox bound is safely 7, with the precondition documented. Decoy arithmetic, forward result 5699, reverse result 41, and 65th-failure cutovers check out. Naive references use correct inclusive bounds and cover fast/fallback paths in both directions." Round 3 (ultra) verified the Copilot-prompted prose and the QuickCheck layer: "Ratio cutover can first occur on failure 5 when scanned < 8; cutover is always by failure 65. Period-82 decoys never trigger the ratio branch and deterministically hit the hard cap. All 2,835 valid corrupted-block constructions preserve anchors, differ from the needle, and cannot form accidental cross-block matches. Both directions force fallback before the mixture." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Codex CLI <codex@openai.com> Signed-off-by: Codex CLI <codex@openai.com>
1 parent dc17f50 commit 8d6e030

5 files changed

Lines changed: 420 additions & 53 deletions
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright 2026 International Digital Economy Academy
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Worst-case benchmarks for substring search: needles whose anchors and
16+
// long prefixes recur at every haystack position, which degrade the
17+
// two-anchor filter to its fallback — plus fast-path guards that must not
18+
// move when the fallback changes.
19+
20+
///|
21+
fn string_find_adversary_needle(half : Int) -> String {
22+
String::make(half, 'a') + "Z" + String::make(half - 1, 'a')
23+
}
24+
25+
///|
26+
test "bench find adversary m=64 n=4096" (it : @bench.T) {
27+
let haystack = String::make(4096, 'a')
28+
let needle = string_find_adversary_needle(32)
29+
it.bench(fn() { it.keep(haystack.find(needle)) })
30+
}
31+
32+
///|
33+
test "bench rev_find adversary m=64 n=4096" (it : @bench.T) {
34+
let haystack = String::make(4096, 'a')
35+
let needle = string_find_adversary_needle(32)
36+
it.bench(fn() { it.keep(haystack.rev_find(needle)) })
37+
}
38+
39+
///|
40+
test "bench find adversary m=512 n=65536" (it : @bench.T) {
41+
let haystack = String::make(65536, 'a')
42+
let needle = string_find_adversary_needle(256)
43+
it.bench(fn() { it.keep(haystack.find(needle)) })
44+
}
45+
46+
///|
47+
test "bench find dense miss fast path n=4096" (it : @bench.T) {
48+
let haystack = String::make(4096, 'a')
49+
it.bench(fn() { it.keep(haystack.find("aaaaZ")) })
50+
}
51+
52+
///|
53+
test "bench find rare hit end fast path n=4096" (it : @bench.T) {
54+
let haystack = String::make(4092, 'a') + "Zabc"
55+
it.bench(fn() { it.keep(haystack.find("Zabc")) })
56+
}

builtin/string_find_code_unit.mbt

Lines changed: 102 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ fn find_by_two_anchors(target : StringView, pattern : StringView) -> Int? {
211211
let failures = failures + 1
212212
let scanned = found - target_start
213213
if two_anchor_should_fallback(failures, scanned) {
214-
break find_pattern_scalar_from(target, pattern, scanned + 1)
214+
break find_pattern_kmp_from(target, pattern, scanned + 1)
215215
}
216216
continue found + 1, failures
217217
} nobreak {
@@ -259,7 +259,7 @@ fn rev_find_by_two_anchors(target : StringView, pattern : StringView) -> Int? {
259259
let failures = failures + 1
260260
let scanned = last_candidate - candidate
261261
if two_anchor_should_fallback(failures, scanned) {
262-
break rev_find_pattern_scalar_before(target, pattern, candidate)
262+
break rev_find_pattern_kmp_before(target, pattern, candidate)
263263
}
264264
continue found, failures
265265
} nobreak {
@@ -269,80 +269,133 @@ fn rev_find_by_two_anchors(target : StringView, pattern : StringView) -> Int? {
269269

270270
///|
271271
// Dense first/last-anchor matches make repeated SIMD candidate scans more
272-
// expensive than direct comparison. Cut over after enough false candidates,
273-
// following the guarded-scanner strategy used by Bytes search.
272+
// expensive than direct comparison. Cut over to the guaranteed-linear KMP
273+
// fallback either early, when failures pile up relative to progress
274+
// (`failures > 4 + scanned / 8` — as soon as the 5th failure for
275+
// candidates packed near the scan start), or at the hard cap
276+
// (`failures > 64`, i.e. the 65th failed verification). Either way the
277+
// failure count at cutover is at most 65, keeping pre-cutover
278+
// verification work at O(pattern), so find/rev_find stay
279+
// O(target + pattern) even on adversarial inputs.
274280
#inline
275281
fn two_anchor_should_fallback(failures : Int, scanned : Int) -> Bool {
276282
failures > 64 || failures > 4 + scanned / 8
277283
}
278284

279285
///|
280-
// Direct forward fallback used after the two-anchor filter encounters dense
281-
// false positives. It checks the middle first because the cutover has already
282-
// established that first/last matches are not selective. `start` is relative
283-
// to `target`.
284-
fn find_pattern_scalar_from(
286+
// Longest-proper-border table for the KMP fallbacks: `table[i]` is the
287+
// length of the longest proper prefix of `pattern[0..=i]` that is also a
288+
// suffix of it. Only built after the two-anchor filter cuts over, so the
289+
// O(pattern) allocation is paid exclusively on pathological inputs.
290+
fn kmp_failure_table(pattern : StringView) -> FixedArray[Int] {
291+
let m = pattern.length()
292+
let table = FixedArray::make(m, 0)
293+
let mut k = 0
294+
for i in 1..<m {
295+
let c = pattern.unsafe_get(i)
296+
while k > 0 && c != pattern.unsafe_get(k) {
297+
k = table[k - 1]
298+
}
299+
if c == pattern.unsafe_get(k) {
300+
k += 1
301+
}
302+
table[i] = k
303+
}
304+
table
305+
}
306+
307+
///|
308+
// Guaranteed-linear forward fallback used after the two-anchor filter
309+
// encounters dense false positives: KMP over the remaining candidates, so
310+
// the whole search stays O(target + pattern) even when both anchors and
311+
// long pattern prefixes recur throughout the target. Returns the first
312+
// occurrence starting at a target-relative position >= `start`.
313+
fn find_pattern_kmp_from(
285314
target : StringView,
286315
pattern : StringView,
287316
start : Int,
288317
) -> Int? {
289-
let pattern_len = pattern.length()
290-
let last_offset = pattern_len - 1
291-
let first = pattern.unsafe_get(0)
292-
let last = pattern.unsafe_get(last_offset)
293-
let last_candidate = target.length() - pattern_len
294-
for candidate in start..<=last_candidate {
295-
let middle_matches = for i in 1..<last_offset {
296-
if target.unsafe_get(candidate + i) != pattern.unsafe_get(i) {
297-
break false
298-
}
299-
} nobreak {
300-
true
318+
let n = target.length()
319+
let m = pattern.length()
320+
let table = kmp_failure_table(pattern)
321+
let mut k = 0
322+
for i in start..<n {
323+
let c = target.unsafe_get(i)
324+
while k > 0 && c != pattern.unsafe_get(k) {
325+
k = table[k - 1]
301326
}
302-
if middle_matches &&
303-
target.unsafe_get(candidate) == first &&
304-
target.unsafe_get(candidate + last_offset) == last {
305-
break Some(candidate)
327+
if c == pattern.unsafe_get(k) {
328+
k += 1
329+
}
330+
if k == m {
331+
return Some(i - m + 1)
306332
}
307-
} nobreak {
308-
None
309333
}
334+
None
310335
}
311336

312337
///|
313-
// Direct reverse fallback used after the two-anchor filter encounters dense
314-
// false positives. It checks the middle first because the cutover has already
315-
// established that first/last matches are not selective. `candidate_end` is
316-
// target-relative and exclusive.
317-
fn rev_find_pattern_scalar_before(
338+
// Guaranteed-linear reverse fallback used after the two-anchor filter
339+
// encounters dense false positives: forward KMP over the prefix that can
340+
// still contain a hit, keeping the rightmost match, so the whole search
341+
// stays O(target + pattern). Returns the last occurrence starting at a
342+
// target-relative position strictly below `candidate_end` (exclusive).
343+
// The caller must ensure
344+
// `candidate_end <= target.length() - pattern.length() + 1`, so that the
345+
// scan below stays in bounds.
346+
fn rev_find_pattern_kmp_before(
318347
target : StringView,
319348
pattern : StringView,
320349
candidate_end : Int,
321350
) -> Int? {
322351
guard candidate_end > 0 else { return None }
323-
let pattern_len = pattern.length()
324-
let last_offset = pattern_len - 1
325-
let first = pattern.unsafe_get(0)
326-
let last = pattern.unsafe_get(last_offset)
327-
for candidate = candidate_end - 1; candidate >= 0; {
328-
let middle_matches = for i in 1..<last_offset {
329-
if target.unsafe_get(candidate + i) != pattern.unsafe_get(i) {
330-
break false
331-
}
332-
} nobreak {
333-
true
352+
let m = pattern.length()
353+
let table = kmp_failure_table(pattern)
354+
// an occurrence starting at candidate_end - 1 ends at index
355+
// candidate_end + m - 2, so that is the last index the scan must visit
356+
let scan_end = candidate_end + m - 1
357+
let mut k = 0
358+
let mut best = -1
359+
for i in 0..<scan_end {
360+
let c = target.unsafe_get(i)
361+
while k > 0 && c != pattern.unsafe_get(k) {
362+
k = table[k - 1]
334363
}
335-
if middle_matches &&
336-
target.unsafe_get(candidate) == first &&
337-
target.unsafe_get(candidate + last_offset) == last {
338-
break Some(candidate)
364+
if c == pattern.unsafe_get(k) {
365+
k += 1
339366
}
340-
continue candidate - 1
341-
} nobreak {
367+
if k == m {
368+
best = i - m + 1
369+
// keep scanning: a later (more rightward) overlapping match wins
370+
k = table[k - 1]
371+
}
372+
}
373+
if best >= 0 {
374+
Some(best)
375+
} else {
342376
None
343377
}
344378
}
345379

380+
///|
381+
test "kmp fallbacks handle periodic and overlapping patterns" {
382+
// failure table borders matter for periodic patterns
383+
let t : StringView = "aaaaaa"
384+
assert_true(find_pattern_kmp_from(t, "aaa", 0) is Some(0))
385+
assert_true(find_pattern_kmp_from(t, "aaa", 2) is Some(2))
386+
assert_true(find_pattern_kmp_from(t, "aaa", 4) is None)
387+
assert_true(rev_find_pattern_kmp_before(t, "aaa", 4) is Some(3))
388+
assert_true(rev_find_pattern_kmp_before(t, "aaa", 1) is Some(0))
389+
let u : StringView = "ababcababab"
390+
assert_true(find_pattern_kmp_from(u, "abab", 0) is Some(0))
391+
assert_true(find_pattern_kmp_from(u, "abab", 1) is Some(5))
392+
assert_true(rev_find_pattern_kmp_before(u, "abab", 8) is Some(7))
393+
assert_true(rev_find_pattern_kmp_before(u, "ababc", 7) is Some(0))
394+
assert_true(find_pattern_kmp_from(u, "abcabc", 0) is None)
395+
// start beyond any match and empty prefix bound
396+
assert_true(rev_find_pattern_kmp_before(u, "abab", 0) is None)
397+
}
398+
346399
///|
347400
// Compares two raw UTF-16 ranges. The caller must ensure both ranges are in
348401
// bounds; using backing strings directly also permits isolated surrogate code

0 commit comments

Comments
 (0)