Skip to main content

vyre_libs/scan/
dispatch_io.rs

1//! Shared GPU dispatch primitives for matching engines.
2//!
3//! Every high-level matcher in `vyre-libs::matching` (`GpuLiteralSet`,
4//! `RulePipeline`, future ones) needs the same four operations to talk
5//! to a `VyreBackend`:
6//!
7//!   1. Pack a haystack `&[u8]` into `u32` words for the read-only
8//!      input storage buffer.
9//!   2. Encode an arbitrary `&[u32]` slice as little-endian bytes for
10//!      a storage buffer.
11//!   3. Validate the haystack's length fits in `u32` (the wire-format
12//!      bound that vyre's IR enforces) and return a typed
13//!      `BackendError` with an actionable `Fix:` message otherwise.
14//!   4. Compute the per-axis grid geometry that maps haystack bytes
15//!      onto the program's `workgroup_size[0]` lane fan-out.
16//!
17//! Each of those was duplicated 2x as I added the second matcher
18//! (`RulePipeline::scan`). Centralising them here makes the *next*
19//! matcher (parser combinators, taint-flow scan, custom regex
20//! compositions in downstream crates) free to compose  -  write the unique
21//! plumbing, reuse the shared four.
22//!
23//! The output-layout step is intentionally **not** centralised:
24//! `GpuLiteralSet` uses a two-buffer layout (`match_count` + `matches`),
25//! while `RulePipeline` uses a single hit buffer with embedded counter.
26//! Once the caller has isolated the counter and match-triple byte range,
27//! decoding is shared so every engine rejects malformed readbacks the same
28//! way.
29
30use std::borrow::Cow;
31
32use vyre::{BackendError, DispatchConfig};
33
34const U32_COUNTER_BYTES: usize = 4;
35const MATCH_TRIPLE_BYTES: usize = 12;
36
37/// Reusable host-side staging for scan dispatches.
38///
39/// Engines that repeatedly scan many haystacks can keep one scratch value per
40/// worker thread and pass it through `*_with_scratch` APIs. This removes the
41/// fixed haystack-packing allocation from every dispatch while preserving the
42/// same borrowed-input backend contract.
43#[derive(Debug, Default)]
44pub struct ScanDispatchScratch {
45    /// Packed little-endian `u32` haystack bytes.
46    pub haystack_bytes: Vec<u8>,
47    /// Optional zeroed hit-buffer staging used by single-buffer hit layouts.
48    pub hit_bytes: Vec<u8>,
49}
50
51/// Pack a haystack of bytes into `u32` little-endian words ready for an
52/// input storage buffer. Each 4 input bytes become one little-endian
53/// `u32`; a tail less than 4 bytes is zero-padded into the high lanes.
54///
55/// This is the layout every vyre matcher's `BufferDecl::storage(..,
56/// DataType::U32, ReadOnly)` haystack input expects.
57///
58/// # Panics
59///
60/// Aborts when padded length arithmetic or allocation fails. Returning an empty
61/// packed buffer would make the GPU scan an EMPTY haystack, silently finding
62/// nothing and reporting the real input as clean (Law 10). Fail closed instead;
63/// callers that must recover use [`try_pack_haystack_u32`].
64#[must_use]
65pub fn pack_haystack_u32(haystack: &[u8]) -> Vec<u8> {
66    match try_pack_haystack_u32(haystack) {
67        Ok(packed) => packed,
68        Err(error) => {
69            panic!(
70                "vyre-libs scan dispatch pack_haystack_u32 failed: {error}. \
71                 returning an empty packed buffer would make the GPU scan an empty haystack and silently report the input as clean; \
72                 use try_pack_haystack_u32 and split the haystack before dispatch."
73            )
74        }
75    }
76}
77
78/// Fallible owned variant of [`pack_haystack_u32`].
79///
80/// # Errors
81///
82/// Returns [`BackendError`] when padded length arithmetic or allocation fails.
83pub fn try_pack_haystack_u32(haystack: &[u8]) -> Result<Vec<u8>, BackendError> {
84    let mut packed = Vec::new();
85    pack_haystack_u32_into(haystack, &mut packed)?;
86    Ok(packed)
87}
88
89/// Pack a haystack into caller-owned scratch.
90///
91/// Clears `packed`, reserves the exact padded byte capacity, copies
92/// `haystack`, and appends zero padding up to the next `u32` word boundary.
93///
94/// # Errors
95///
96/// Returns [`BackendError`] when padded length arithmetic or allocation fails.
97pub fn pack_haystack_u32_into(haystack: &[u8], packed: &mut Vec<u8>) -> Result<(), BackendError> {
98    let padded_len = haystack_padded_u32_byte_len(haystack.len())?;
99    packed.clear();
100    vyre_foundation::allocation::try_reserve_vec_to_capacity(packed, padded_len).map_err(
101        |source| {
102            BackendError::new(format!(
103                "scan dispatch could not reserve {padded_len} packed haystack byte(s): {source}. Fix: split the haystack before dispatch."
104            ))
105        },
106    )?;
107    packed.extend_from_slice(haystack);
108    packed.resize(padded_len, 0);
109    Ok(())
110}
111
112/// Byte length of `byte_len` haystack bytes packed and zero-padded to the next
113/// `u32` word boundary, the exact size a resident haystack buffer must be
114/// allocated at so [`pack_haystack_u32_into`] output uploads in place.
115pub fn haystack_padded_u32_byte_len(byte_len: usize) -> Result<usize, BackendError> {
116    byte_len
117        .checked_add(3)
118        .map(|len| (len / 4) * 4)
119        .ok_or_else(|| {
120            BackendError::new(
121                "scan dispatch haystack padding overflows host usize. Fix: split the haystack before dispatch.",
122            )
123        })
124}
125
126#[cfg(test)]
127mod scratch_reuse_tests {
128    use super::{
129        haystack_padded_u32_byte_len, pack_haystack_u32, pack_haystack_u32_into,
130        try_pack_haystack_u32, ScanDispatchScratch,
131    };
132
133    #[test]
134    fn pack_haystack_into_reuses_capacity_and_matches_owned_helper() {
135        let mut scratch = ScanDispatchScratch::default();
136        pack_haystack_u32_into(b"abcdef", &mut scratch.haystack_bytes)
137            .expect("Fix: packed haystack scratch should reserve");
138        let retained = scratch.haystack_bytes.capacity();
139        assert_eq!(scratch.haystack_bytes, pack_haystack_u32(b"abcdef"));
140
141        pack_haystack_u32_into(b"xy", &mut scratch.haystack_bytes)
142            .expect("Fix: smaller packed haystack should reuse scratch");
143
144        assert_eq!(scratch.haystack_bytes, vec![b'x', b'y', 0, 0]);
145        assert!(scratch.haystack_bytes.capacity() >= retained);
146    }
147
148    #[test]
149    fn try_pack_haystack_owned_matches_compat_helper() {
150        let packed = try_pack_haystack_u32(b"abcde")
151            .expect("Fix: small owned haystack packing must reserve");
152
153        assert_eq!(packed, pack_haystack_u32(b"abcde"));
154        assert_eq!(packed, vec![b'a', b'b', b'c', b'd', b'e', 0, 0, 0]);
155    }
156
157    #[test]
158    fn haystack_padding_overflow_reports_split_fix() {
159        let error = haystack_padded_u32_byte_len(usize::MAX)
160            .expect_err("Fix: usize::MAX padding must overflow instead of wrapping");
161        let message = format!("{error}");
162
163        assert!(message.contains("padding overflows host usize"));
164        assert!(message.contains("Fix: split the haystack"));
165    }
166}
167
168/// Pack a `&[u32]` into a little-endian `Vec<u8>` suitable for upload
169/// to a storage buffer of type `DataType::U32`.
170#[must_use]
171pub fn pack_u32_slice(words: &[u32]) -> Vec<u8> {
172    vyre_primitives::wire::pack_u32_slice(words)
173}
174
175/// Borrow a `u32` slice as little-endian bytes on little-endian hosts,
176/// falling back to an owned conversion on big-endian targets.
177#[must_use]
178pub fn u32_words_as_le_bytes(words: &[u32]) -> Cow<'_, [u8]> {
179    if cfg!(target_endian = "little") {
180        Cow::Borrowed(bytemuck::cast_slice(words))
181    } else {
182        Cow::Owned(pack_u32_slice(words))
183    }
184}
185
186/// Validate that `haystack.len()` fits in a `u32` and return it. Vyre's
187/// IR uses `u32` for buffer indices, and most matching kernels rely on
188/// it indirectly via 4 GiB-bounded loop counters; the check belongs at
189/// the dispatch boundary so the user-facing error message points at the
190/// real fix (split the input).
191///
192/// # Errors
193/// Returns a `BackendError` carrying the message
194/// `"<context> haystack length exceeds u32 capacity. Fix: split the
195/// scan into chunks smaller than 4 GiB."` so callers can include their
196/// engine name in the surfaced diagnostic.
197pub fn haystack_len_u32(haystack: &[u8], context: &str) -> Result<u32, BackendError> {
198    u32::try_from(haystack.len()).map_err(|_| {
199        BackendError::new(format!(
200            "{context} haystack length exceeds u32 capacity. \
201             Fix: split the scan into chunks smaller than 4 GiB."
202        ))
203    })
204}
205
206/// Default scan-guard ceiling. Picked at 1 GiB on the assumption that
207/// a single GPU dispatch over more than 1 GiB of haystack is almost
208/// always a caller bug  -  fragmenting at this granularity keeps device
209/// allocations bounded and lets failed segments retry independently.
210/// Callers that genuinely need the full u32 range pass `u32::MAX` to
211/// [`scan_guard`].
212pub const DEFAULT_MAX_SCAN_BYTES: u32 = 1 << 30;
213
214/// Pre-dispatch length check: enforce both the hard `u32` cap (the IR
215/// limit) **and** a configurable `max_bytes` ceiling (the
216/// caller-policy limit) in one call. Returns the validated length so
217/// callers don't need a separate `u32::try_from` site.
218///
219/// This is the single source of truth for "how big a haystack will
220/// vyre accept on this dispatch?"  -  every matcher in `vyre-libs` is
221/// expected to call it before assembling input buffers, so the
222/// surface message on overflow is uniform across engines.
223///
224/// # Errors
225/// Returns a [`BackendError`] when:
226/// - `haystack.len()` exceeds `u32::MAX` (carries the
227///   `haystack_len_u32` overflow message).
228/// - `haystack.len()` exceeds `max_bytes` (carries a
229///   `Fix: split the scan…` message that names the limit).
230pub fn scan_guard(haystack: &[u8], context: &str, max_bytes: u32) -> Result<u32, BackendError> {
231    let len = haystack_len_u32(haystack, context)?;
232    if len > max_bytes {
233        return Err(BackendError::new(format!(
234            "{context} haystack length {len} bytes exceeds scan-guard ceiling {max_bytes} bytes. \
235             Fix: split the scan into chunks <= {max_bytes} bytes, or pass a larger \
236             max_bytes if the larger dispatch is intentional."
237        )));
238    }
239    Ok(len)
240}
241
242/// Compute the standard "one workgroup per `workgroup_size[0]` haystack
243/// bytes" grid geometry. Every byte-scan matcher in `vyre-libs::matching`
244/// uses the same X-axis lane fan-out, so callers should not duplicate
245/// this divceil-clamp arithmetic at every dispatch site.
246#[must_use]
247pub fn byte_scan_dispatch_config(haystack_len: u32, workgroup_x: u32) -> DispatchConfig {
248    let mut config = DispatchConfig::default();
249    let workgroups = haystack_len.div_ceil(workgroup_x.max(1)).max(1);
250    config.grid_override = Some([workgroups, 1, 1]);
251    // The element-grid covers `haystack_len` bytes (one lane per byte). Record the
252    // true coverage so a shape-inferring backend (CpuRefBackend) dispatches the
253    // whole haystack instead of silently under-covering the tail (Law 10).
254    config.dispatch_elements = Some(haystack_len);
255    config
256}
257
258/// Compute grid geometry for matchers that assign one workgroup to
259/// each candidate start offset. Subgroup-local lanes cooperate inside
260/// that workgroup to advance the automaton state, so X-grid density is
261/// the input byte count rather than `haystack_len / workgroup_size`.
262#[must_use]
263pub fn candidate_start_dispatch_config(haystack_len: u32) -> DispatchConfig {
264    let mut config = DispatchConfig::default();
265    config.grid_override = Some([haystack_len.max(1), 1, 1]);
266    // One workgroup per candidate byte: the coverage is `haystack_len` bytes.
267    config.dispatch_elements = Some(haystack_len);
268    config
269}
270
271/// Decode a little-endian scan counter from the first four bytes of a backend
272/// readback buffer.
273///
274/// # Errors
275///
276/// Returns [`BackendError`] when the readback is shorter than one `u32`.
277pub fn try_read_u32_prefix(bytes: &[u8], field: &'static str) -> Result<u32, BackendError> {
278    if bytes.len() < U32_COUNTER_BYTES {
279        return Err(BackendError::new(format!(
280            "scan dispatch {field} was {} byte(s) but a u32 counter requires {U32_COUNTER_BYTES} bytes. Fix: preserve the counter output byte range before decoding scan results.",
281            bytes.len()
282        )));
283    }
284
285    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
286}
287
288/// Borrow one backend output buffer by declaration index.
289///
290/// # Errors
291///
292/// Returns [`BackendError`] when the backend omitted a declared output slot.
293pub fn try_output_bytes<'a>(
294    outputs: &'a [Vec<u8>],
295    index: usize,
296    field: &'static str,
297) -> Result<&'a [u8], BackendError> {
298    outputs.get(index).map(Vec::as_slice).ok_or_else(|| {
299        BackendError::new(format!(
300            "scan dispatch missing {field} at output index {index}; backend returned {} output buffer(s). Fix: preserve Program output declaration order and return every declared output buffer.",
301            outputs.len()
302        ))
303    })
304}
305
306/// Decode a packed match-triple buffer (`pid, start, end` × N) into
307/// [`vyre_foundation::match_result::Match`] values. The triple layout is
308/// shared between `GpuLiteralSet` and `RulePipeline`; only the *position*
309/// of the buffer in the dispatch outputs differs.
310///
311/// Decodes at most `count` triples and never reads past a complete 12-byte
312/// record, so the returned length is
313/// `min(count, triples_bytes.len() / 12)`. Extra bytes after the last full
314/// triple are ignored. Using a `usize` lane index keeps `i * 12` inside
315/// buffer-derived bounds and avoids `(i as usize) * 12` wrapping on 32-bit
316/// targets when `count` is large but the buffer is short.
317///
318/// # Panics
319/// Panics when the triple buffer cannot be decoded under the u32 match ABI. Returning
320/// an empty match set would silently drop matches the GPU found, so callers that must
321/// recover use [`try_unpack_match_triples`].
322#[must_use]
323pub fn unpack_match_triples(
324    triples_bytes: &[u8],
325    count: u32,
326) -> Vec<vyre_foundation::match_result::Match> {
327    match try_unpack_match_triples(triples_bytes, count) {
328        Ok(results) => results,
329        Err(error) => {
330            // Returning an empty match set would silently drop every match the
331            // GPU actually found, a recall-loss silent fallback (Law 10). Fail
332            // closed; callers that must recover use try_unpack_match_triples.
333            panic!(
334                "vyre-libs scan dispatch unpack_match_triples failed: {error}. \
335                 returning an empty match set would silently drop matches the GPU found; \
336                 use try_unpack_match_triples and reduce the match count or reserve more storage."
337            )
338        }
339    }
340}
341
342/// Fallible owned variant of [`unpack_match_triples`].
343///
344/// # Errors
345///
346/// Returns [`BackendError`] when decoded match storage cannot be reserved.
347pub fn try_unpack_match_triples(
348    triples_bytes: &[u8],
349    count: u32,
350) -> Result<Vec<vyre_foundation::match_result::Match>, BackendError> {
351    let mut results = Vec::new();
352    try_unpack_match_triples_into(triples_bytes, count, &mut results)?;
353    Ok(results)
354}
355
356/// Caller-owned variant of [`unpack_match_triples`].
357///
358/// Reuses `results` across dispatches and therefore removes one hot
359/// allocation from benchmark loops and long-running daemons. The decode
360/// contract is identical to [`unpack_match_triples`]: at most `count`
361/// complete triples are read, truncated tail bytes are ignored, and the
362/// final output is sorted by [`vyre_foundation::match_result::Match`]'s
363/// ordering.
364///
365/// # Panics
366/// Panics when the triple buffer cannot be decoded under the u32 match ABI; see
367/// [`unpack_match_triples`]. Callers that must recover use
368/// [`try_unpack_match_triples_into`].
369pub fn unpack_match_triples_into(
370    triples_bytes: &[u8],
371    count: u32,
372    results: &mut Vec<vyre_foundation::match_result::Match>,
373) {
374    if let Err(error) = try_unpack_match_triples_into(triples_bytes, count, results) {
375        // Clearing `results` would silently drop every match the GPU found, a
376        // recall-loss silent fallback (Law 10). Fail closed; callers that must
377        // recover use try_unpack_match_triples_into.
378        panic!(
379            "vyre-libs scan dispatch unpack_match_triples_into failed: {error}. \
380             clearing the result buffer would silently drop matches the GPU found; \
381             use try_unpack_match_triples_into and reduce the match count or reserve more storage."
382        )
383    }
384}
385
386/// Fallible caller-owned variant of [`unpack_match_triples_into`].
387///
388/// # Errors
389///
390/// Returns [`BackendError`] when decoded match storage cannot be reserved.
391pub fn try_unpack_match_triples_into(
392    triples_bytes: &[u8],
393    count: u32,
394    results: &mut Vec<vyre_foundation::match_result::Match>,
395) -> Result<(), BackendError> {
396    let n = decoded_match_triple_count(triples_bytes, count);
397    vyre_foundation::allocation::try_reserve_vec_to_capacity(results, n).map_err(|source| {
398        BackendError::new(format!(
399            "scan dispatch could not reserve {n} decoded match record(s): {source}. Fix: lower max_matches or split the scan before dispatch."
400        ))
401    })?;
402    results.clear();
403    for i in 0..n {
404        let off = i * 12;
405        let pid = u32::from_le_bytes([
406            triples_bytes[off],
407            triples_bytes[off + 1],
408            triples_bytes[off + 2],
409            triples_bytes[off + 3],
410        ]);
411        let start = u32::from_le_bytes([
412            triples_bytes[off + 4],
413            triples_bytes[off + 5],
414            triples_bytes[off + 6],
415            triples_bytes[off + 7],
416        ]);
417        let end = u32::from_le_bytes([
418            triples_bytes[off + 8],
419            triples_bytes[off + 9],
420            triples_bytes[off + 10],
421            triples_bytes[off + 11],
422        ]);
423        results.push(vyre_foundation::match_result::Match::new(pid, start, end));
424    }
425    results.sort_unstable();
426    Ok(())
427}
428
429/// Strict caller-owned variant for bounded scan readbacks.
430///
431/// Unlike [`try_unpack_match_triples_into`], this helper rejects a backend
432/// buffer that cannot hold exactly the `count` complete triples requested by
433/// the caller; short buffers are backend/readback corruption, not a successful
434/// partial decode.
435///
436/// `count` MUST already be proven `<= max_matches` (the fixed output-buffer
437/// capacity). Do NOT pass `count.min(max_matches)` here to clamp an
438/// over-capacity kernel counter: that hands back a truncated set the caller
439/// cannot distinguish from a complete one, a SILENT dropped-match false
440/// negative (Law 10). Route every fixed-capacity GPU match readback through
441/// [`try_unpack_match_triples_capped_into`], which fails closed on overflow and
442/// only then calls this.
443///
444/// # Errors
445///
446/// Returns [`BackendError`] when `count` cannot be represented on this host,
447/// when the required byte length overflows `usize`, when the readback is too
448/// short for `count`, or when decoded match storage cannot be reserved.
449pub fn try_unpack_match_triples_exact_prefix_into(
450    triples_bytes: &[u8],
451    count: u32,
452    results: &mut Vec<vyre_foundation::match_result::Match>,
453) -> Result<(), BackendError> {
454    results.clear();
455    let required = required_match_triple_bytes(count)?;
456    if triples_bytes.len() < required {
457        return Err(BackendError::new(format!(
458            "scan dispatch match triples readback was {} byte(s) but count={count} requires {required} byte(s). Fix: preserve the output byte range for the requested match cap before decoding scan results.",
459            triples_bytes.len()
460        )));
461    }
462    try_unpack_match_triples_into(triples_bytes, count, results)
463}
464
465/// Capacity-guarded match-triple decode: the ONE primitive every fixed-capacity
466/// GPU match readback must use. Fails closed when the kernel's reported `count`
467/// exceeds the output-buffer cap.
468///
469/// The GPU match counter is an atomic incremented for EVERY match the kernel
470/// finds, including matches at slots past `cap` it could not write (the emit
471/// is guarded by `slot < cap`, the counter is not). So `count > cap` is the
472/// exact, host-detectable signal that `count - cap` matches were dropped.
473/// Decoding the `min(count, cap)` prefix instead would return a truncated set
474/// indistinguishable from a complete scan: a SILENT false negative, the worst
475/// failure mode for a scanner (Law 10). This surfaces the overflow as an error
476/// naming the shortfall and the fix; on success `count` is proven `<= cap` and
477/// decoded exactly via [`try_unpack_match_triples_exact_prefix_into`].
478///
479/// `context` labels the readback (e.g. `"RulePipeline hit buffer"`) so the
480/// error points the operator at the dispatch that overflowed.
481///
482/// # Errors
483///
484/// Returns [`BackendError`] when `count > cap` (overflow ⇒ dropped matches), or
485/// for any error [`try_unpack_match_triples_exact_prefix_into`] raises.
486pub fn try_unpack_match_triples_capped_into(
487    triples_bytes: &[u8],
488    count: u32,
489    cap: u32,
490    context: &str,
491    results: &mut Vec<vyre_foundation::match_result::Match>,
492) -> Result<(), BackendError> {
493    if count > cap {
494        results.clear();
495        return Err(BackendError::new(format!(
496            "{context}: GPU match count {count} exceeds the output-buffer cap {cap}; decoding would silently drop {} match(es). Fix: raise the match cap (max_matches) or split the scan before dispatch.",
497            count - cap
498        )));
499    }
500    try_unpack_match_triples_exact_prefix_into(triples_bytes, count, results)
501}
502
503#[inline]
504fn decoded_match_triple_count(triples_bytes: &[u8], count: u32) -> usize {
505    let max_complete = triples_bytes.len() / MATCH_TRIPLE_BYTES;
506    let requested = match usize::try_from(count) {
507        Ok(requested) => requested,
508        Err(_) => usize::MAX,
509    };
510    requested.min(max_complete)
511}
512
513fn required_match_triple_bytes(count: u32) -> Result<usize, BackendError> {
514    let n = usize::try_from(count).map_err(|source| {
515        BackendError::new(format!(
516            "scan dispatch match count does not fit host usize: {source}. Fix: lower max_matches or split the scan before dispatch."
517        ))
518    })?;
519    n.checked_mul(MATCH_TRIPLE_BYTES).ok_or_else(|| {
520        BackendError::new(
521            "scan dispatch match triple byte count overflowed host usize. Fix: lower max_matches or split the scan before dispatch.",
522        )
523    })
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn dispatch_io_wrappers_fail_loud_not_silent_fallback() {
532        // Law 10 regression guard: the infallible pack/unpack wrappers must
533        // never swallow an error into an empty buffer (GPU scans nothing / GPU
534        // matches silently dropped). The old arms logged the failure and then
535        // returned/cleared to empty instead of failing loud. Scan the WHOLE
536        // file (this file has two test modules with production code between
537        // them, so a "split on first cfg-test" slice would miss the unpack
538        // region). The swallow marker is assembled with concat! and is NOT
539        // written contiguously anywhere else in this file (including comments),
540        // so this assertion never matches its own source text.
541        let src = include_str!("dispatch_io.rs");
542        let swallow_marker = concat!("eprintln", "!(\"vyre-libs scan dispatch ");
543        assert!(
544            !src.contains(swallow_marker),
545            "Fix: a dispatch wrapper reintroduced an eprintln!-then-return-empty silent fallback (Law 10) (fail loud via panic!() so callers use the try_ variants)."
546        );
547    }
548
549    #[test]
550    fn capped_decode_fails_closed_when_count_exceeds_cap() {
551        // Law 10 regression: the kernel's atomic match counter overcounts past
552        // the fixed output cap (the emit is `slot < cap`-guarded, the counter is
553        // not), so `count > cap` means matches were dropped. The capped decode
554        // MUST surface that, never hand back the silently-truncated prefix.
555        // Forge 4 complete triples but a counter claiming 9 hits.
556        let mut triples = Vec::new();
557        for i in 0..4u32 {
558            triples.extend_from_slice(&i.to_le_bytes()); // pattern_id
559            triples.extend_from_slice(&i.to_le_bytes()); // start
560            triples.extend_from_slice(&(i + 2).to_le_bytes()); // end
561        }
562        let mut results = vec![vyre_foundation::match_result::Match::new(7, 7, 7)];
563        let err =
564            try_unpack_match_triples_capped_into(&triples, 9, 4, "unit cap test", &mut results)
565                .expect_err("count 9 over cap 4 must fail closed, not truncate");
566        let msg = err.to_string();
567        assert!(
568            msg.contains("unit cap test")
569                && msg.contains("exceeds the output-buffer cap 4")
570                && msg.contains("drop 5 match(es)"),
571            "error must name the context, cap, and dropped count: {msg}"
572        );
573        assert!(
574            results.is_empty(),
575            "a failed capped decode must expose no partial matches, got {results:?}"
576        );
577    }
578
579    #[test]
580    fn capped_decode_passes_and_decodes_exactly_within_cap() {
581        // Within the cap the decode is exact: count == 3 of a 4-slot buffer
582        // yields exactly those 3 triples (assert the real values, not is_empty).
583        let mut triples = Vec::new();
584        for i in 0..4u32 {
585            triples.extend_from_slice(&(i + 10).to_le_bytes()); // pattern_id
586            triples.extend_from_slice(&i.to_le_bytes()); // start
587            triples.extend_from_slice(&(i + 1).to_le_bytes()); // end
588        }
589        let mut results = Vec::new();
590        try_unpack_match_triples_capped_into(&triples, 3, 4, "unit within-cap", &mut results)
591            .expect("count 3 within cap 4 must decode");
592        assert_eq!(
593            results,
594            vec![
595                vyre_foundation::match_result::Match::new(10, 0, 1),
596                vyre_foundation::match_result::Match::new(11, 1, 2),
597                vyre_foundation::match_result::Match::new(12, 2, 3),
598            ],
599            "within-cap decode must yield exactly the first `count` triples"
600        );
601        // The exact-cap boundary (count == cap) is allowed, not an overflow.
602        let mut at_cap = Vec::new();
603        try_unpack_match_triples_capped_into(&triples, 4, 4, "unit at-cap", &mut at_cap)
604            .expect("count == cap is not an overflow");
605        assert_eq!(at_cap.len(), 4, "count == cap must decode all four");
606    }
607
608    #[test]
609    fn pack_haystack_aligned() {
610        let bytes = b"abcdefgh";
611        let packed = pack_haystack_u32(bytes);
612        // Two LE u32 words: "abcd" → 0x64636261, "efgh" → 0x68676665.
613        assert_eq!(packed, vec![0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]);
614    }
615
616    #[test]
617    fn pack_haystack_unaligned_zero_pads() {
618        let bytes = b"abc";
619        let packed = pack_haystack_u32(bytes);
620        // Single u32: "abc\0" → 0x00636261. Tail high lane is 0.
621        assert_eq!(packed, vec![0x61, 0x62, 0x63, 0x00]);
622    }
623
624    #[test]
625    fn pack_haystack_empty() {
626        assert!(pack_haystack_u32(&[]).is_empty());
627    }
628
629    #[test]
630    fn pack_u32_slice_layout() {
631        let words: [u32; 2] = [0x01020304, 0xAABBCCDD];
632        assert_eq!(
633            pack_u32_slice(&words),
634            vec![0x04, 0x03, 0x02, 0x01, 0xDD, 0xCC, 0xBB, 0xAA]
635        );
636    }
637
638    #[test]
639    fn u32_words_as_le_bytes_matches_pack_layout() {
640        let words: [u32; 2] = [0x01020304, 0xAABBCCDD];
641        let bytes = u32_words_as_le_bytes(&words);
642        assert_eq!(
643            bytes.as_ref(),
644            [0x04, 0x03, 0x02, 0x01, 0xDD, 0xCC, 0xBB, 0xAA]
645        );
646        if cfg!(target_endian = "little") {
647            assert!(matches!(bytes, std::borrow::Cow::Borrowed(_)));
648        }
649    }
650
651    #[test]
652    fn haystack_len_under_4gib_ok() {
653        let buf = vec![0u8; 1024];
654        assert_eq!(haystack_len_u32(&buf, "test").unwrap(), 1024);
655    }
656
657    #[test]
658    fn scan_guard_under_ceiling_ok() {
659        let buf = vec![0u8; 1024];
660        assert_eq!(
661            scan_guard(&buf, "test", DEFAULT_MAX_SCAN_BYTES).unwrap(),
662            1024
663        );
664    }
665
666    #[test]
667    fn scan_guard_over_ceiling_errors() {
668        let buf = vec![0u8; 1024];
669        let err = scan_guard(&buf, "test", 512).expect_err("over ceiling must err");
670        let msg = format!("{err}");
671        assert!(
672            msg.contains("scan-guard ceiling"),
673            "scan_guard error must name the ceiling, got: {msg}"
674        );
675        assert!(
676            msg.contains("512"),
677            "must echo the ceiling number, got: {msg}"
678        );
679    }
680
681    #[test]
682    fn scan_guard_zero_ceiling_rejects_nonempty() {
683        let buf = vec![0u8; 1];
684        let err = scan_guard(&buf, "ctx", 0).expect_err("nonempty haystack with zero ceiling");
685        let msg = err.to_string();
686        assert!(
687            msg.contains("scan-guard ceiling") && msg.contains('0'),
688            "zero-ceiling rejection must name the ceiling: {msg}"
689        );
690    }
691
692    #[test]
693    fn scan_guard_zero_ceiling_accepts_empty() {
694        let buf: Vec<u8> = vec![];
695        assert_eq!(scan_guard(&buf, "ctx", 0).unwrap(), 0);
696    }
697
698    #[test]
699    fn scan_guard_at_max_u32_ceiling_accepts_real_inputs() {
700        let buf = vec![0u8; 1 << 16];
701        assert_eq!(scan_guard(&buf, "ctx", u32::MAX).unwrap(), 1 << 16);
702    }
703
704    #[test]
705    fn dispatch_config_clamps_at_one() {
706        // Haystack shorter than a single workgroup must still yield ≥1
707        // workgroup so the kernel actually runs.
708        let cfg = byte_scan_dispatch_config(0, 64);
709        assert_eq!(cfg.grid_override, Some([1, 1, 1]));
710    }
711
712    #[test]
713    fn dispatch_config_divceils() {
714        let cfg = byte_scan_dispatch_config(129, 64);
715        assert_eq!(cfg.grid_override, Some([3, 1, 1]));
716    }
717
718    #[test]
719    fn byte_scan_config_carries_true_element_coverage() {
720        // The grid_override is a WORKGROUP count (haystack_len / workgroup), which a
721        // shape-inferring backend cannot turn back into the byte coverage. The
722        // separate dispatch_elements field carries the true haystack byte count so
723        // CpuRefBackend covers the whole scan and does not silently drop the tail
724        // (Law 10). candidate-start's coverage is likewise the full haystack length.
725        assert_eq!(
726            byte_scan_dispatch_config(129, 64).dispatch_elements,
727            Some(129)
728        );
729        assert_eq!(byte_scan_dispatch_config(0, 64).dispatch_elements, Some(0));
730        assert_eq!(
731            candidate_start_dispatch_config(65_536).dispatch_elements,
732            Some(65_536)
733        );
734        // A bare/megakernel config leaves it None so its work-queue grid_override is
735        // never misread as an element count and over-run.
736        assert_eq!(DispatchConfig::default().dispatch_elements, None);
737    }
738
739    #[test]
740    fn unpack_match_triples_sorts() {
741        let bytes = [
742            // (pid=2, start=10, end=20)
743            2, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, // (pid=1, start=5, end=8)
744            1, 0, 0, 0, 5, 0, 0, 0, 8, 0, 0, 0,
745        ];
746        let matches = unpack_match_triples(&bytes, 2);
747        assert_eq!(matches.len(), 2);
748        // sort_unstable orders by (start, end, pid) via Match's Ord impl.
749        assert!(matches[0].start <= matches[1].start);
750    }
751
752    #[test]
753    fn unpack_match_triples_into_reuses_caller_buffer() {
754        let bytes = [
755            2, 0, 0, 0, 10, 0, 0, 0, 20, 0, 0, 0, 1, 0, 0, 0, 5, 0, 0, 0, 8, 0, 0, 0,
756        ];
757        let mut matches = Vec::with_capacity(8);
758        let ptr = matches.as_ptr();
759
760        unpack_match_triples_into(&bytes, 2, &mut matches);
761
762        assert_eq!(matches.len(), 2);
763        assert_eq!(matches.as_ptr(), ptr);
764        assert!(matches[0].start <= matches[1].start);
765    }
766
767    #[test]
768    fn try_unpack_match_triples_into_keeps_fallible_hot_path_reusable() {
769        let bytes = [
770            9, 0, 0, 0, 40, 0, 0, 0, 44, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 8, 0, 0, 0,
771        ];
772        let mut matches = Vec::with_capacity(4);
773        let ptr = matches.as_ptr();
774
775        try_unpack_match_triples_into(&bytes, 2, &mut matches)
776            .expect("Fix: small decoded match buffer must reserve");
777
778        assert_eq!(matches.len(), 2);
779        assert_eq!(matches.as_ptr(), ptr);
780        assert_eq!(matches[0].pattern_id, 3);
781        assert_eq!(matches[1].pattern_id, 9);
782    }
783
784    #[test]
785    fn try_unpack_match_triples_owned_matches_compat_helper() {
786        let bytes = [
787            5, 0, 0, 0, 11, 0, 0, 0, 13, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 7, 0, 0, 0,
788        ];
789
790        assert_eq!(
791            try_unpack_match_triples(&bytes, 2)
792                .expect("Fix: small decoded match buffer must reserve"),
793            unpack_match_triples(&bytes, 2)
794        );
795    }
796
797    #[test]
798    fn read_u32_prefix_decodes_counter_and_rejects_short_readback() {
799        assert_eq!(
800            try_read_u32_prefix(&[0x34, 0x12, 0, 0, 0xAA], "test counter")
801                .expect("Fix: four-byte counter prefix must decode"),
802            0x1234
803        );
804
805        let err = try_read_u32_prefix(&[1, 2, 3], "test counter")
806            .expect_err("short scan counter readback must fail closed");
807        let msg = err.to_string();
808        assert!(
809            msg.contains("test counter")
810                && msg.contains("3 byte(s)")
811                && msg.contains("requires 4 bytes"),
812            "short counter error must name the field and required length: {msg}"
813        );
814    }
815
816    #[test]
817    fn output_bytes_rejects_missing_declared_output_slot() {
818        let outputs = vec![vec![1, 2, 3, 4]];
819        assert_eq!(
820            try_output_bytes(&outputs, 0, "first").expect("Fix: present output slot must borrow"),
821            &[1, 2, 3, 4]
822        );
823
824        let err = try_output_bytes(&outputs, 1, "matches")
825            .expect_err("missing backend output slot must fail closed");
826        let msg = err.to_string();
827        assert!(
828            msg.contains("matches")
829                && msg.contains("output index 1")
830                && msg.contains("returned 1 output buffer"),
831            "missing output error must identify the omitted slot: {msg}"
832        );
833    }
834
835    #[test]
836    fn exact_prefix_match_decode_sorts_and_reuses_caller_buffer() {
837        let bytes = [
838            9, 0, 0, 0, 40, 0, 0, 0, 44, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 8, 0, 0, 0, 0xAA, 0xBB,
839        ];
840        let mut matches = Vec::with_capacity(4);
841        let ptr = matches.as_ptr();
842
843        try_unpack_match_triples_exact_prefix_into(&bytes, 2, &mut matches)
844            .expect("Fix: exact two-triple prefix must decode");
845
846        assert_eq!(matches.len(), 2);
847        assert_eq!(matches.as_ptr(), ptr);
848        assert_eq!(matches[0].pattern_id, 3);
849        assert_eq!(matches[1].pattern_id, 9);
850    }
851
852    #[test]
853    fn exact_prefix_match_decode_rejects_short_payload_and_clears_results() {
854        let bytes = [
855            7u8, 0, 0, 0, // pid
856            1, 0, 0, 0, // start
857            3, 0, 0, 0, // end
858        ];
859        let mut matches = vec![vyre_foundation::match_result::Match::new(99, 1, 2)];
860
861        let err = try_unpack_match_triples_exact_prefix_into(&bytes, 2, &mut matches)
862            .expect_err("short match triple readback must fail closed");
863
864        let msg = err.to_string();
865        assert!(
866            matches.is_empty(),
867            "malformed readback must clear stale matches"
868        );
869        assert!(
870            msg.contains("readback was 12 byte(s)")
871                && msg.contains("count=2")
872                && msg.contains("requires 24 byte(s)"),
873            "short match readback error must identify observed and required bytes: {msg}"
874        );
875    }
876
877    #[test]
878    fn exact_prefix_match_decode_huge_count_short_payload_fails_closed() {
879        let bytes = [
880            7u8, 0, 0, 0, // pid
881            1, 0, 0, 0, // start
882            3, 0, 0, 0, // end
883        ];
884        let mut matches = vec![vyre_foundation::match_result::Match::new(99, 1, 2)];
885
886        let err = try_unpack_match_triples_exact_prefix_into(&bytes, u32::MAX, &mut matches)
887            .expect_err("huge count with short readback must fail closed");
888
889        let msg = err.to_string();
890        assert!(
891            matches.is_empty(),
892            "malformed readback must clear stale matches"
893        );
894        assert!(
895            msg.contains("requires") || msg.contains("overflowed") || msg.contains("does not fit"),
896            "huge-count error must report required size or host capacity: {msg}"
897        );
898    }
899
900    /// Adversarial / regression: a bogus or truncated readback may pair a
901    /// huge `count` (e.g. `u32::MAX`) with a short buffer. The decoder must
902    /// only walk full 12-byte triples so we never form `off` from a wrapped
903    /// `u32_index * 12` on 32-bit `usize` before comparing to `len`, and we
904    /// return exactly the complete records present (not a silent under-filled
905    /// long vec).
906    #[test]
907    fn unpack_match_triples_huge_count_short_buffer_stays_in_bounds() {
908        let bytes = [
909            7u8, 0, 0, 0, // pid
910            1, 0, 0, 0, // start
911            3, 0, 0, 0, // end
912        ];
913        let matches = unpack_match_triples(&bytes, u32::MAX);
914        assert_eq!(matches.len(), 1);
915        assert_eq!(matches[0].pattern_id, 7);
916        assert_eq!(matches[0].start, 1);
917        assert_eq!(matches[0].end, 3);
918    }
919}