Skip to main content

vyre_libs/scan/substring/
substring.rs

1//! Brute-force substring search  -  each invocation checks whether the
2//! needle matches at its starting byte offset, writes `1` to the
3//! match bitmap at that offset on hit.
4//!
5//! Category A composition. Sufficient for short needles; long
6//! needles should compile to a DFA via the future `dfa_compile`
7//! function and use that as a prefilter.
8
9use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
10
11use crate::region::wrap_anonymous;
12
13/// Canonical scan op id.
14pub const SCAN_SUBSTRING_OP_ID: &str = "vyre-libs::scan::substring_search";
15
16/// Build a Program that writes `1` to `matches[i]` when `haystack[i..]`
17/// starts with `needle`, else `0`. Both buffers are u32 byte arrays
18/// packed one byte per u32 for simplicity (a future packed-u8 version
19/// is Category A over `DataType::U8`).
20#[must_use]
21pub fn substring_search(
22    haystack: &str,
23    needle: &str,
24    matches: &str,
25    haystack_len: u32,
26    needle_len: u32,
27) -> Program {
28    build_substring_program(haystack, needle, matches, haystack_len, needle_len)
29}
30
31fn build_substring_program(
32    haystack: &str,
33    needle: &str,
34    matches: &str,
35    haystack_len: u32,
36    needle_len: u32,
37) -> Program {
38    let counted_storage = |name: &str, binding, count| {
39        let decl = BufferDecl::storage(name, binding, BufferAccess::ReadOnly, DataType::U32);
40        if count == 0 {
41            decl
42        } else {
43            decl.with_count(count)
44        }
45    };
46    let output_count = haystack_len.max(1);
47    let visible_output_bytes = (haystack_len as usize).saturating_mul(4);
48    let output = BufferDecl::output(matches, 2, DataType::U32)
49        .with_count(output_count)
50        .with_output_byte_range(0..visible_output_bytes);
51
52    let i = Expr::var("i");
53    // ok accumulates AND of per-byte equality checks. Start at 1; each
54    // byte mismatch AND-s in 0 and latches the match bit off.
55    let mut check_body: Vec<Node> = vec![Node::let_bind("ok", Expr::u32(1))];
56    // Walk the needle one byte at a time. bytes are packed u32/byte for
57    // simplicity  -  a packed-u8 variant is Category A over DataType::U8.
58    check_body.push(Node::loop_for(
59        "k",
60        Expr::u32(0),
61        Expr::u32(needle_len),
62        vec![Node::assign(
63            "ok",
64            Expr::bitand(
65                Expr::var("ok"),
66                // Select turns the bool comparison into u32 {0,1} so
67                // the accumulator stays in integer arithmetic.
68                Expr::select(
69                    Expr::eq(
70                        Expr::load(haystack, Expr::add(i.clone(), Expr::var("k"))),
71                        Expr::load(needle, Expr::var("k")),
72                    ),
73                    Expr::u32(1),
74                    Expr::u32(0),
75                ),
76            ),
77        )],
78    ));
79    check_body.push(Node::Store {
80        buffer: matches.into(),
81        index: i.clone(),
82        value: Expr::var("ok"),
83    });
84
85    // Overflow-safe guard. The straight expression `i + needle_len <= buf_len`
86    // can wrap at i ≈ u32::MAX − needle_len, producing a false positive on the
87    // last few offsets. The correct invariant is `i <= buf_len - needle_len`;
88    // we rewrite it as a subtraction-free chain of comparisons by reasoning
89    // through `buf_len` only:
90    //
91    //   needle_len <= buf_len  ∧  i + needle_len <= buf_len
92    //
93    // Passing both conjuncts also handles the empty-haystack case (buf_len=0,
94    // needle_len=0, i=0 → both true → vacuous check_body).
95    // V7-CORR-006: the original guard `i + needle_len <= haystack_len`
96    // wraps when i is near u32::MAX (Expr::add is Expr::BinOp { Add,
97    // .. } which u32::wrapping_add). We rewrite as two separate
98    // non-wrapping comparisons: (1) needle_len <= haystack_len ensures
99    // the implicit subtraction in (2) is non-underflowing, and (2)
100    // `i <= haystack_len - needle_len` keeps the rhs a constant-folded
101    // expression from the builder so no wrap is possible. Since
102    // needle_len is a compile-time u32 and haystack_len is a runtime
103    // u32, the host-side `saturating_sub` pre-computes the cap value
104    // safely and lets Expr::le do the comparison without Expr::add.
105    let body = vec![
106        Node::let_bind("i", Expr::InvocationId { axis: 0 }),
107        Node::let_bind("haystack_len", Expr::buf_len(haystack)),
108        Node::if_then(
109            Expr::and(
110                Expr::le(Expr::u32(needle_len), Expr::var("haystack_len")),
111                Expr::le(
112                    i.clone(),
113                    // `haystack_len - needle_len` as a runtime Expr sub. If
114                    // the compile-time needle_len exceeds the runtime
115                    // haystack_len the first conjunct already short-
116                    // circuits, so this sub-expression is evaluated only
117                    // on the safe branch (eager vs lazy evaluation is
118                    // the job of the optimizer's short-circuit pass  -
119                    // here the conjunct ordering gives a safe guard).
120                    Expr::sub(Expr::var("haystack_len"), Expr::u32(needle_len)),
121                ),
122            ),
123            check_body,
124        ),
125    ];
126    Program::wrapped(
127        vec![
128            counted_storage(haystack, 0, haystack_len),
129            counted_storage(needle, 1, needle_len),
130            output,
131        ],
132        [64, 1, 1],
133        vec![wrap_anonymous(SCAN_SUBSTRING_OP_ID, body)],
134    )
135}
136
137inventory::submit! {
138    vyre_foundation::operation::OperationRegistration {
139        semantic_version: 1,
140        signature: None,
141        tier: vyre_foundation::operation::OperationTier::Library,
142        laws: &[],
143        tolerance: vyre_foundation::operation::TolerancePolicy::EXACT,
144        id: SCAN_SUBSTRING_OP_ID,
145        build: Some(|| substring_search("haystack", "needle", "matches", 8, 3)),
146        test_inputs: Some(|| {
147            let to_u32_vec = |s: &str| s.bytes().map(u32::from).collect::<Vec<_>>();
148            vec![
149                vec![
150                    crate::fixture_bytes::u32_bytes(&to_u32_vec("abcabc++")),
151                    crate::fixture_bytes::u32_bytes(&to_u32_vec("abc")),
152                ],
153                vec![
154                    crate::fixture_bytes::u32_bytes(&to_u32_vec("xyzxyzxy")),
155                    crate::fixture_bytes::u32_bytes(&to_u32_vec("xyz")),
156                ]
157            ]
158        }),
159        expected_output: Some(|| {
160            // Case 0: haystack="abcabc++", needle="abc". Matches at
161            //   i ∈ {0, 3}. Positions i > haystack_len - needle_len
162            //   (5) stay at their zero init because the guard never
163            //   fires.
164            // Case 1: haystack="xyzxyzxy", needle="xyz". Matches at
165            //   i ∈ {0, 3}.
166            let case0 = crate::fixture_bytes::u32_bytes(&[1u32, 0, 0, 1, 0, 0, 0, 0]);
167            let case1 = crate::fixture_bytes::u32_bytes(&[1u32, 0, 0, 1, 0, 0, 0, 0]);
168            vec![vec![case0], vec![case1]]
169        }),
170        category: Some("scan"),
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn builder_uses_canonical_scan_op_id() {
180        let program = substring_search("haystack", "needle", "matches", 8, 3);
181        let [Node::Region { generator, .. }] = program.entry() else {
182            panic!("expected substring search to emit one scan region");
183        };
184
185        assert_eq!(generator.as_str(), SCAN_SUBSTRING_OP_ID);
186    }
187}