Skip to main content

vyre_driver_wgpu/megakernel/
batch.rs

1//! Device-resident multi-file batch containers for the megakernel path.
2//!
3//! `FileBatch` packs many files into one contiguous haystack buffer,
4//! uploads the prefix-sum offsets + metadata tables once, and keeps a
5//! persistent device-derived work schedule + sparse hit ring alive across dispatches.
6
7use super::dispatcher::TransitionWidth;
8use super::segmentation;
9use crate::buffer::GpuBufferHandle;
10use crate::staging_reserve::reserve_vec_exact_for_len;
11use std::sync::Arc;
12use vyre_runtime::PipelineError;
13
14/// Number of `u32` words stored per file metadata record.
15pub const FILE_METADATA_WORDS: usize = 4;
16/// Number of `u32` words stored per work item.
17pub const WORK_TRIPLE_WORDS: usize = 3;
18/// Number of `u32` words stored per sparse hit record.
19pub const HIT_RECORD_WORDS: usize = 4;
20/// Number of control words stored in the persistent queue-state buffer.
21pub const QUEUE_STATE_WORDS: usize = 6;
22/// Maximum device work-item claims accepted by one uploaded file batch.
23pub const MAX_BATCH_WORK_ITEMS: usize = u32::MAX as usize;
24/// Maximum sparse hit records accepted by one uploaded file batch.
25pub const MAX_BATCH_HIT_CAPACITY: u32 = 16 * 1024 * 1024;
26
27pub(crate) fn persistent_storage_binding_usage() -> wgpu::BufferUsages {
28    wgpu::BufferUsages::STORAGE
29        | wgpu::BufferUsages::COPY_SRC
30        | wgpu::BufferUsages::COPY_DST
31        | wgpu::BufferUsages::INDIRECT
32}
33
34/// Queue-state word indices.
35pub mod queue_state_word {
36    /// Next work-item index to claim.
37    pub const HEAD: usize = 0;
38    /// Total work items available in the queue.
39    pub const QUEUE_LEN: usize = 1;
40    /// Next sparse-hit slot to publish.
41    pub const HIT_HEAD: usize = 2;
42    /// Sparse-hit ring capacity.
43    pub const HIT_CAPACITY: usize = 3;
44    /// Total work items completed by the device.
45    pub const DONE_COUNT: usize = 4;
46    /// Rule fanout used to derive `(seg_idx, rule_idx)` from a claim id.
47    /// `seg_idx = claim / rule_count` indexes the `segments` table, whose row
48    /// `[file_idx, scan_start, emit_start, emit_end]` (file-relative) fully
49    /// describes the window, no segmentation control words live here, the
50    /// device decode reads the table directly (see `dispatcher.rs`).
51    pub const RULE_COUNT: usize = 5;
52}
53
54/// Host-side file input for batch construction.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct BatchFile {
57    /// Stable hash of the file path.
58    pub path_hash: u64,
59    /// Decoded-layer index this file belongs to.
60    pub decoded_layer_index: u32,
61    /// Raw file bytes.
62    pub bytes: Vec<u8>,
63}
64
65impl BatchFile {
66    /// Build one batchable file record.
67    #[must_use]
68    pub fn new(path_hash: u64, decoded_layer_index: u32, bytes: Vec<u8>) -> Self {
69        Self {
70            path_hash,
71            decoded_layer_index,
72            bytes,
73        }
74    }
75}
76
77/// Per-file metadata mirrored into the device metadata table.
78#[repr(C)]
79#[derive(Debug, Clone, Copy, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
80pub struct FileMetadata {
81    /// Low 32 bits of the path hash.
82    pub path_hash_lo: u32,
83    /// High 32 bits of the path hash.
84    pub path_hash_hi: u32,
85    /// File byte length.
86    pub size_bytes: u32,
87    /// Decoded-layer index.
88    pub decoded_layer_index: u32,
89}
90
91impl FileMetadata {
92    fn from_file(file: &BatchFile) -> Result<Self, PipelineError> {
93        Ok(Self {
94            path_hash_lo: file.path_hash as u32,
95            path_hash_hi: (file.path_hash >> 32) as u32,
96            size_bytes: u32::try_from(file.bytes.len()).map_err(|_| PipelineError::QueueFull {
97                queue: "submission",
98                fix: "file size exceeds u32::MAX; split the batch into smaller files before megakernel batching",
99            })?,
100            decoded_layer_index: file.decoded_layer_index,
101        })
102    }
103}
104
105/// Device work item `(file_idx, rule_idx, layer_idx)`.
106#[repr(C)]
107#[derive(Debug, Clone, Copy, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
108pub struct WorkTriple {
109    /// File-table index.
110    pub file_idx: u32,
111    /// Rule-table index.
112    pub rule_idx: u32,
113    /// Decoded-layer index.
114    pub layer_idx: u32,
115}
116
117impl WorkTriple {
118    /// Build one queue entry.
119    #[must_use]
120    pub const fn new(file_idx: u32, rule_idx: u32, layer_idx: u32) -> Self {
121        Self {
122            file_idx,
123            rule_idx,
124            layer_idx,
125        }
126    }
127}
128
129/// Sparse hit emitted by the batched kernel.
130#[repr(C)]
131#[derive(Debug, Clone, Copy, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)]
132pub struct HitRecord {
133    /// File-table index.
134    pub file_idx: u32,
135    /// Rule-table index.
136    pub rule_idx: u32,
137    /// Decoded-layer index.
138    pub layer_idx: u32,
139    /// Byte offset relative to the file start.
140    pub match_offset: u32,
141}
142
143/// Persistent device-owned batch buffers.
144#[derive(Clone)]
145pub struct FileBatch {
146    device_queue: Arc<(wgpu::Device, wgpu::Queue)>,
147    file_metadata: Vec<FileMetadata>,
148    file_offsets: Vec<u32>,
149    haystack_words: Vec<u32>,
150    rule_count: u32,
151    queue_len: u32,
152    hit_capacity: u32,
153    haystack: GpuBufferHandle,
154    offsets: GpuBufferHandle,
155    metadata: GpuBufferHandle,
156    /// Flat segment table: `segment_count * SEGMENT_WORDS` u32s, each row
157    /// `[file_idx, scan_start, emit_start, emit_end]` (file-relative). The
158    /// device claim decode reads `seg_idx = claim / rule_count` rows from here.
159    segments: GpuBufferHandle,
160    queue_state: GpuBufferHandle,
161    hit_ring: GpuBufferHandle,
162}
163
164/// Telemetry for one in-place [`FileBatch`] refresh.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub struct FileBatchRefreshReport {
167    /// Host-to-device bytes written for refreshed logical input prefixes.
168    pub bytes_uploaded: u64,
169    /// New resident GPU allocations required because refreshed data exceeded
170    /// existing allocation capacity.
171    pub resident_allocations: u32,
172    /// Resident buffers refreshed in place.
173    pub reused_buffers: u32,
174    /// Resident buffers replaced with new allocations.
175    pub refreshed_buffers: u32,
176}
177
178impl std::fmt::Debug for FileBatch {
179    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        formatter
181            .debug_struct("FileBatch")
182            .field("file_count", &self.file_count())
183            .field("queue_len", &self.queue_len())
184            .field("haystack_bytes", &self.haystack.byte_len())
185            .field("hit_capacity", &self.hit_capacity)
186            .finish()
187    }
188}
189
190impl FileBatch {
191    /// Upload a new multi-file batch into persistent GPU buffers.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`PipelineError::QueueFull`] when the batch exceeds the
196    /// current `u32` table limits or the work queue would overflow.
197    pub fn upload(
198        device_queue: Arc<(wgpu::Device, wgpu::Queue)>,
199        files: &[BatchFile],
200        rule_count: u32,
201        hit_capacity: u32,
202    ) -> Result<Self, PipelineError> {
203        validate_hit_capacity(hit_capacity)?;
204        let (device, queue) = &*device_queue;
205        validate_batch_shape(files, rule_count)?;
206        let mut file_metadata = Vec::new();
207        let mut file_offsets = Vec::new();
208        let mut haystack_words = Vec::new();
209        build_metadata_into(files, &mut file_metadata)?;
210        build_offsets_into(files, &mut file_offsets)?;
211        flatten_haystack_words_into(files, &mut haystack_words)?;
212        let (seg_len, overlap) = default_segmentation(file_metadata.len());
213        let segment_words = build_segment_table(&file_metadata, seg_len, overlap);
214        let queue_len = segment_queue_len(&segment_words, rule_count)?;
215        let queue_state_words = initial_queue_state(queue_len, hit_capacity, rule_count);
216
217        let haystack = GpuBufferHandle::upload(
218            device,
219            queue,
220            bytemuck::cast_slice(&haystack_words),
221            persistent_storage_binding_usage(),
222        )?;
223        let offsets = GpuBufferHandle::upload(
224            device,
225            queue,
226            bytemuck::cast_slice(&file_offsets),
227            persistent_storage_binding_usage(),
228        )?;
229        let metadata = GpuBufferHandle::upload(
230            device,
231            queue,
232            bytemuck::cast_slice(&file_metadata),
233            persistent_storage_binding_usage(),
234        )?;
235        let segments = GpuBufferHandle::upload(
236            device,
237            queue,
238            bytemuck::cast_slice(&segment_words),
239            persistent_storage_binding_usage(),
240        )?;
241        let queue_state = GpuBufferHandle::upload(
242            device,
243            queue,
244            bytemuck::cast_slice(&queue_state_words),
245            persistent_storage_binding_usage(),
246        )?;
247        let hit_ring_bytes = hit_ring_byte_len(hit_capacity)?;
248        let hit_ring =
249            GpuBufferHandle::alloc(device, hit_ring_bytes, persistent_storage_binding_usage())?;
250
251        Ok(Self {
252            device_queue,
253            file_metadata,
254            file_offsets,
255            haystack_words,
256            rule_count,
257            queue_len,
258            hit_capacity,
259            haystack,
260            offsets,
261            metadata,
262            segments,
263            queue_state,
264            hit_ring,
265        })
266    }
267
268    /// Refresh this batch in place, reusing host staging vectors and resident
269    /// GPU buffers whenever the new batch fits existing allocations.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`PipelineError::QueueFull`] before mutating the batch when the
274    /// requested file/rule fanout cannot fit the megakernel batch protocol.
275    pub fn refresh(
276        &mut self,
277        files: &[BatchFile],
278        rule_count: u32,
279        hit_capacity: u32,
280    ) -> Result<(), PipelineError> {
281        self.refresh_with_report(files, rule_count, hit_capacity)
282            .map(|_| ())
283    }
284
285    /// Refresh this batch in place and return allocation/transfer telemetry.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`PipelineError::QueueFull`] before mutating the batch when the
290    /// requested file/rule fanout cannot fit the megakernel batch protocol.
291    pub fn refresh_with_report(
292        &mut self,
293        files: &[BatchFile],
294        rule_count: u32,
295        hit_capacity: u32,
296    ) -> Result<FileBatchRefreshReport, PipelineError> {
297        validate_hit_capacity(hit_capacity)?;
298        validate_batch_shape(files, rule_count)?;
299
300        build_metadata_into(files, &mut self.file_metadata)?;
301        build_offsets_into(files, &mut self.file_offsets)?;
302        flatten_haystack_words_into(files, &mut self.haystack_words)?;
303
304        let (seg_len, overlap) = default_segmentation(self.file_metadata.len());
305        let segment_words = build_segment_table(&self.file_metadata, seg_len, overlap);
306        let queue_len = segment_queue_len(&segment_words, rule_count)?;
307        self.rule_count = rule_count;
308        self.queue_len = queue_len;
309        self.hit_capacity = hit_capacity;
310        let queue_state_words = initial_queue_state(queue_len, hit_capacity, rule_count);
311        let (device, queue) = &*self.device_queue;
312        let mut report = FileBatchRefreshReport::default();
313        accumulate_refresh(
314            &mut report,
315            &mut self.haystack,
316            device,
317            queue,
318            bytemuck::cast_slice(&self.haystack_words),
319            persistent_storage_binding_usage(),
320        )?;
321        accumulate_refresh(
322            &mut report,
323            &mut self.offsets,
324            device,
325            queue,
326            bytemuck::cast_slice(&self.file_offsets),
327            persistent_storage_binding_usage(),
328        )?;
329        accumulate_refresh(
330            &mut report,
331            &mut self.metadata,
332            device,
333            queue,
334            bytemuck::cast_slice(&self.file_metadata),
335            persistent_storage_binding_usage(),
336        )?;
337        accumulate_refresh(
338            &mut report,
339            &mut self.segments,
340            device,
341            queue,
342            bytemuck::cast_slice(&segment_words),
343            persistent_storage_binding_usage(),
344        )?;
345        accumulate_refresh(
346            &mut report,
347            &mut self.queue_state,
348            device,
349            queue,
350            bytemuck::cast_slice(&queue_state_words),
351            persistent_storage_binding_usage(),
352        )?;
353        let hit_ring_bytes = hit_ring_byte_len(hit_capacity)?;
354        if self.hit_ring.allocation_len() < padded_write_len_u64(hit_ring_bytes)? {
355            self.hit_ring =
356                GpuBufferHandle::alloc(device, hit_ring_bytes, persistent_storage_binding_usage())?;
357            report.resident_allocations += 1;
358            report.refreshed_buffers += 1;
359        } else {
360            report.reused_buffers += 1;
361        }
362        Ok(report)
363    }
364
365    /// Reset the persistent queue indices before another dispatch.
366    ///
367    /// # Errors
368    ///
369    /// Returns [`PipelineError::Backend`] when the queue-state upload fails.
370    pub fn reset_queue_state(&self) -> Result<(), PipelineError> {
371        let (_, queue) = &*self.device_queue;
372        let words = initial_queue_state(self.queue_len, self.hit_capacity, self.rule_count);
373        queue.write_buffer(self.queue_state.buffer(), 0, bytemuck::cast_slice(&words));
374        Ok(())
375    }
376
377    /// Re-tile this batch at a new window geometry: rebuild the `segments` table
378    /// (each file split into `ceil(len / seg_len)` windows, files shorter than
379    /// `seg_len` staying whole), grow/rewrite the resident segments buffer, and
380    /// update `queue_len = segment_count * rule_count` + the queue-state header so
381    /// the next dispatch claims the new work space. `seg_len = u32::MAX` restores
382    /// one segment per file (the legacy whole-file scan).
383    ///
384    /// SOUNDNESS CONTRACT: `overlap` MUST be at least
385    /// [`segmentation::catalog_sync_overlap`] over the rules that will scan this
386    /// batch. With a shorter warm-up a window can reconstruct the wrong DFA state
387    /// at `emit_start` and drop or fabricate matches. The geometry is the
388    /// caller's decision (it owns the rule catalog); this method enforces only the
389    /// structural invariant `seg_len > 0`.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`PipelineError::QueueFull`] when `seg_len == 0` or the new work
394    /// queue overflows the device claim protocol, or [`PipelineError::Backend`]
395    /// when the buffer upload fails.
396    pub fn set_segmentation(&mut self, seg_len: u32, overlap: u32) -> Result<(), PipelineError> {
397        if seg_len == 0 {
398            return Err(PipelineError::QueueFull {
399                queue: "submission",
400                fix: "segment owned-width (seg_len) must be > 0; pass u32::MAX for one segment per file",
401            });
402        }
403        let segment_words = build_segment_table(&self.file_metadata, seg_len, overlap);
404        let queue_len = segment_queue_len(&segment_words, self.rule_count)?;
405        self.queue_len = queue_len;
406        let queue_state_words = initial_queue_state(queue_len, self.hit_capacity, self.rule_count);
407        let (device, queue) = &*self.device_queue;
408        let mut report = FileBatchRefreshReport::default();
409        accumulate_refresh(
410            &mut report,
411            &mut self.segments,
412            device,
413            queue,
414            bytemuck::cast_slice(&segment_words),
415            persistent_storage_binding_usage(),
416        )?;
417        queue.write_buffer(
418            self.queue_state.buffer(),
419            0,
420            bytemuck::cast_slice(&queue_state_words),
421        );
422        Ok(())
423    }
424
425    /// Number of files in the batch.
426    #[must_use]
427    pub fn file_count(&self) -> usize {
428        self.file_metadata.len()
429    }
430
431    /// Number of queued `(file, rule, layer)` items.
432    #[must_use]
433    pub const fn queue_len(&self) -> u32 {
434        self.queue_len
435    }
436
437    /// Sparse-hit capacity.
438    #[must_use]
439    pub const fn hit_capacity(&self) -> u32 {
440        self.hit_capacity
441    }
442
443    /// Device queue used for every buffer in this batch.
444    #[must_use]
445    pub fn device_queue(&self) -> Arc<(wgpu::Device, wgpu::Queue)> {
446        Arc::clone(&self.device_queue)
447    }
448
449    /// Packed haystack buffer.
450    #[must_use]
451    pub const fn haystack(&self) -> &GpuBufferHandle {
452        &self.haystack
453    }
454
455    /// Prefix-sum offset table. Length = `file_count + 1`.
456    #[must_use]
457    pub const fn offsets(&self) -> &GpuBufferHandle {
458        &self.offsets
459    }
460
461    /// Per-file metadata table.
462    #[must_use]
463    pub const fn metadata(&self) -> &GpuBufferHandle {
464        &self.metadata
465    }
466
467    /// Flat segment table (`segment_count * SEGMENT_WORDS` u32s). The device
468    /// claim decode reads row `seg_idx = claim / rule_count` to derive the
469    /// window `(file_idx, scan_start, emit_start, emit_end)`.
470    #[must_use]
471    pub const fn segments(&self) -> &GpuBufferHandle {
472        &self.segments
473    }
474
475    /// Queue-state/control words.
476    #[must_use]
477    pub const fn queue_state(&self) -> &GpuBufferHandle {
478        &self.queue_state
479    }
480
481    /// Sparse output ring.
482    #[must_use]
483    pub const fn hit_ring(&self) -> &GpuBufferHandle {
484        &self.hit_ring
485    }
486
487    /// Host-side file metadata.
488    #[must_use]
489    pub fn host_metadata(&self) -> &[FileMetadata] {
490        &self.file_metadata
491    }
492
493    /// Host-side prefix offsets.
494    #[must_use]
495    pub fn host_offsets(&self) -> &[u32] {
496        &self.file_offsets
497    }
498
499    /// Host-side dense work queue.
500    ///
501    /// Dense batches derive work items on-device, so there are no host
502    /// materialized triples to expose.
503    #[must_use]
504    pub fn host_work_items(&self) -> &[WorkTriple] {
505        &[]
506    }
507}
508
509/// Device-resident batch for the COMBINED Aho-Corasick segmented megakernel.
510///
511/// Where [`FileBatch`] uploads per-rule DFA tables and runs one work item per
512/// `(segment, rule)`, `CombinedBatch` uploads ONE flattened combined automaton
513/// (`transitions` / `output_offsets` / `output_records`) and runs one work item
514/// per segment (`queue_len = segment_count`). It reuses every FileBatch host
515/// primitive (metadata, offsets, haystack packing, segment tiling, queue-state
516/// header, sparse hit ring) verbatim; only the automaton buffers and the work
517/// dimension differ.
518///
519/// LAYERING: this type takes the automaton as RAW flattened `u32` arrays, it
520/// holds NO pattern / Aho-Corasick knowledge. Building the automaton from
521/// patterns (`vyre_libs::scan::classic_ac::classic_ac_compile`) lives in the
522/// caller, because `vyre-libs` sits ABOVE this crate in the dependency graph.
523///
524/// SOUNDNESS: the segment warm-up `overlap` is fixed to `max_pattern_len` (a
525/// match ending at a window's `emit_start` can begin up to `max_pattern_len-1`
526/// bytes earlier, so the window must rescan that prefix to reconstruct the DFA
527/// state). This is the overlap the `segmentation.rs` `combined_segmented_scan`
528/// CPU oracle proves equal to a linear `classic_ac_scan`. It is NOT caller-
529/// tunable: the automaton determines it.
530#[derive(Clone)]
531pub struct CombinedBatch {
532    device_queue: Arc<(wgpu::Device, wgpu::Queue)>,
533    file_metadata: Vec<FileMetadata>,
534    queue_len: u32,
535    hit_capacity: u32,
536    max_pattern_len: u32,
537    /// Byte-class count of the compressed transition table (`<= 256`). Baked
538    /// into the kernel program (the automaton fixes it), so the pipeline is
539    /// compiled once per resident catalog.
540    num_classes: u32,
541    /// Device packing of each transition target (`Bits32` default, or `Bits16`
542    /// two-per-word). The dispatcher reads this to build a matching kernel.
543    transition_width: TransitionWidth,
544    haystack: GpuBufferHandle,
545    offsets: GpuBufferHandle,
546    metadata: GpuBufferHandle,
547    /// Byte-class compressed transition table (`state_count * num_classes`),
548    /// packed per [`CombinedBatch::transition_width`].
549    transitions: GpuBufferHandle,
550    output_offsets: GpuBufferHandle,
551    output_records: GpuBufferHandle,
552    /// 256-entry byte→class map.
553    class_maps: GpuBufferHandle,
554    segments: GpuBufferHandle,
555    queue_state: GpuBufferHandle,
556    hit_ring: GpuBufferHandle,
557}
558
559impl std::fmt::Debug for CombinedBatch {
560    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
561        formatter
562            .debug_struct("CombinedBatch")
563            .field("file_count", &self.file_metadata.len())
564            .field("queue_len", &self.queue_len)
565            .field("hit_capacity", &self.hit_capacity)
566            .field("max_pattern_len", &self.max_pattern_len)
567            .finish()
568    }
569}
570
571impl CombinedBatch {
572    /// Upload a combined-AC batch into persistent GPU buffers.
573    ///
574    /// `transitions` is the dense `state_count * 256` byte→next-state table,
575    /// `output_offsets` the `state_count + 1` CSR row pointers, and
576    /// `output_records` the flat pattern-id payload. `max_pattern_len` is the
577    /// longest pattern in the automaton (the warm-up overlap). `seg_len` is the
578    /// per-segment owned width.
579    ///
580    /// # Throughput: `seg_len` is the device-saturation lever, choose it
581    ///
582    /// `seg_len = u32::MAX` is one segment per file: a single serial DFA walk
583    /// with NO intra-file parallelism. It is correctness-equivalent to the
584    /// pre-segmentation path but leaves the device almost entirely idle
585    /// ~0.01x Hyperscan on an 8 MiB input on the reference RTX 5090. It is a
586    /// correctness floor, never a performance default; passing it (or any
587    /// coarse `seg_len`) silently lands the caller on the slow path.
588    ///
589    /// Tiling the file into many overlapping windows is what saturates the
590    /// device. Throughput climbs as `seg_len` shrinks (more resident windows)
591    /// until the fixed `overlap = max_pattern_len` warm-up per window starts to
592    /// dominate, then turns over. On the reference RTX 5090 the 8 MiB optimum is
593    /// `seg_len ~= 128` (~15.6x Hyperscan for a 32-literal catalog, ~8.5x at
594    /// 2048 literals), turning over by 64, see the `megakernel_combined_scan`
595    /// geometry sweep, which pins the conservation + throughput contract across
596    /// `u32::MAX..=64`. The exact optimum shifts with device core count and
597    /// catalog, so tune per host; but ANY fine window beats whole-file by orders
598    /// of magnitude, so never ship `u32::MAX` as the production geometry.
599    ///
600    /// # Errors
601    ///
602    /// Returns [`PipelineError::QueueFull`] when the batch exceeds `u32` table
603    /// limits or the work queue overflows, or [`PipelineError::Backend`] when
604    /// the automaton arrays are internally inconsistent (a malformed automaton
605    /// is rejected, never silently scanned).
606    #[allow(clippy::too_many_arguments)]
607    pub fn upload(
608        device_queue: Arc<(wgpu::Device, wgpu::Queue)>,
609        files: &[BatchFile],
610        transitions: &[u32],
611        output_offsets: &[u32],
612        output_records: &[u32],
613        state_count: u32,
614        max_pattern_len: u32,
615        seg_len: u32,
616        hit_capacity: u32,
617    ) -> Result<Self, PipelineError> {
618        Self::upload_with_transition_width(
619            device_queue,
620            files,
621            transitions,
622            output_offsets,
623            output_records,
624            state_count,
625            max_pattern_len,
626            seg_len,
627            hit_capacity,
628            TransitionWidth::Bits32,
629        )
630    }
631
632    /// Like [`CombinedBatch::upload`] but choosing the device transition-table
633    /// packing. `TransitionWidth::Bits16` halves the table and bytes-per-
634    /// transaction (the large-catalog-scale L1 lever) and FAILS CLOSED if any target
635    /// exceeds `u16::MAX`: never silently truncates a next-state (Law 10).
636    ///
637    /// # Errors
638    ///
639    /// As [`CombinedBatch::upload`], plus [`PipelineError::Backend`] when
640    /// `Bits16` is requested but a transition target does not fit `u16`.
641    #[allow(clippy::too_many_arguments)]
642    pub fn upload_with_transition_width(
643        device_queue: Arc<(wgpu::Device, wgpu::Queue)>,
644        files: &[BatchFile],
645        transitions: &[u32],
646        output_offsets: &[u32],
647        output_records: &[u32],
648        state_count: u32,
649        max_pattern_len: u32,
650        seg_len: u32,
651        hit_capacity: u32,
652        transition_width: TransitionWidth,
653    ) -> Result<Self, PipelineError> {
654        validate_hit_capacity(hit_capacity)?;
655        validate_combined_automaton(transitions, output_offsets, output_records, state_count)?;
656        if seg_len == 0 {
657            return Err(PipelineError::QueueFull {
658                queue: "submission",
659                fix: "segment owned-width (seg_len) must be > 0; pass u32::MAX for one segment per file",
660            });
661        }
662        let (device, queue) = &*device_queue;
663        let mut file_metadata = Vec::new();
664        let mut file_offsets = Vec::new();
665        let mut haystack_words = Vec::new();
666        build_metadata_into(files, &mut file_metadata)?;
667        build_offsets_into(files, &mut file_offsets)?;
668        flatten_haystack_words_into(files, &mut haystack_words)?;
669        let segment_words = build_segment_table(&file_metadata, seg_len, max_pattern_len);
670        // One work item per segment: rule_count == 1 in the dense primitive.
671        let queue_len = segment_queue_len(&segment_words, 1)?;
672        let queue_state_words = initial_queue_state(queue_len, hit_capacity, 1);
673
674        // LOSSLESS byte-class compression of the dense combined transition table,
675        // reusing the SAME primitive the per-rule catalog packer uses (so the
676        // "identical column ⇒ same class" contract has one owner). Shrinks the
677        // table from `state_count * 256` to `state_count * num_classes`, which
678        // is what keeps a thousand-state catalog resident in L2.
679        let mut class_map = Vec::new();
680        let num_classes = vyre_runtime::megakernel::rule_catalog::build_byte_class_map_for_table(
681            transitions,
682            state_count as usize,
683            &mut class_map,
684        );
685        let mut compressed_transitions = Vec::new();
686        vyre_runtime::megakernel::rule_catalog::compress_dense_transitions_into(
687            transitions,
688            state_count as usize,
689            &class_map,
690            num_classes,
691            &mut compressed_transitions,
692        );
693        // Bits16 packs two targets per word (halving bytes/transaction) and FAILS
694        // CLOSED on any target > u16::MAX, never silently truncates (Law 10). The
695        // kernel built by the dispatcher unpacks to match `transition_width`.
696        let transition_table = match transition_width {
697            TransitionWidth::Bits32 => compressed_transitions,
698            TransitionWidth::Bits16 => {
699                let mut packed = Vec::new();
700                vyre_runtime::megakernel::rule_catalog::try_pack_u16_transitions_into(
701                    &compressed_transitions,
702                    &mut packed,
703                )?;
704                packed
705            }
706        };
707
708        let usage = persistent_storage_binding_usage();
709        let haystack =
710            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(&haystack_words), usage)?;
711        let offsets =
712            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(&file_offsets), usage)?;
713        let metadata =
714            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(&file_metadata), usage)?;
715        let transitions = GpuBufferHandle::upload(
716            device,
717            queue,
718            bytemuck::cast_slice(&transition_table),
719            usage,
720        )?;
721        let output_offsets =
722            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(output_offsets), usage)?;
723        let output_records =
724            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(output_records), usage)?;
725        let class_maps =
726            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(&class_map), usage)?;
727        let segments =
728            GpuBufferHandle::upload(device, queue, bytemuck::cast_slice(&segment_words), usage)?;
729        let queue_state = GpuBufferHandle::upload(
730            device,
731            queue,
732            bytemuck::cast_slice(&queue_state_words),
733            usage,
734        )?;
735        let hit_ring_bytes = hit_ring_byte_len(hit_capacity)?;
736        let hit_ring = GpuBufferHandle::alloc(device, hit_ring_bytes, usage)?;
737
738        Ok(Self {
739            device_queue,
740            file_metadata,
741            queue_len,
742            hit_capacity,
743            max_pattern_len,
744            num_classes,
745            transition_width,
746            haystack,
747            offsets,
748            metadata,
749            transitions,
750            output_offsets,
751            output_records,
752            class_maps,
753            segments,
754            queue_state,
755            hit_ring,
756        })
757    }
758
759    /// Re-tile this batch at a new segment owned-width, keeping the proven
760    /// `overlap = max_pattern_len`. `seg_len = u32::MAX` restores one segment
761    /// per file.
762    ///
763    /// # Errors
764    ///
765    /// Returns [`PipelineError::QueueFull`] when `seg_len == 0` or the new work
766    /// queue overflows, or [`PipelineError::Backend`] on upload failure.
767    pub fn set_segmentation(&mut self, seg_len: u32) -> Result<(), PipelineError> {
768        if seg_len == 0 {
769            return Err(PipelineError::QueueFull {
770                queue: "submission",
771                fix: "segment owned-width (seg_len) must be > 0; pass u32::MAX for one segment per file",
772            });
773        }
774        let segment_words = build_segment_table(&self.file_metadata, seg_len, self.max_pattern_len);
775        let queue_len = segment_queue_len(&segment_words, 1)?;
776        self.queue_len = queue_len;
777        let queue_state_words = initial_queue_state(queue_len, self.hit_capacity, 1);
778        let (device, queue) = &*self.device_queue;
779        let mut report = FileBatchRefreshReport::default();
780        accumulate_refresh(
781            &mut report,
782            &mut self.segments,
783            device,
784            queue,
785            bytemuck::cast_slice(&segment_words),
786            persistent_storage_binding_usage(),
787        )?;
788        queue.write_buffer(
789            self.queue_state.buffer(),
790            0,
791            bytemuck::cast_slice(&queue_state_words),
792        );
793        Ok(())
794    }
795
796    /// Reset the persistent queue indices before another dispatch.
797    pub fn reset_queue_state(&self) {
798        let (_, queue) = &*self.device_queue;
799        let words = initial_queue_state(self.queue_len, self.hit_capacity, 1);
800        queue.write_buffer(self.queue_state.buffer(), 0, bytemuck::cast_slice(&words));
801    }
802
803    /// Device work-queue length (== segment count).
804    #[must_use]
805    pub const fn queue_len(&self) -> u32 {
806        self.queue_len
807    }
808
809    /// Sparse hit-ring capacity.
810    #[must_use]
811    pub const fn hit_capacity(&self) -> u32 {
812        self.hit_capacity
813    }
814
815    /// Shared device + queue handle.
816    #[must_use]
817    pub fn device_queue(&self) -> Arc<(wgpu::Device, wgpu::Queue)> {
818        Arc::clone(&self.device_queue)
819    }
820
821    /// Byte-class count of the compressed transition table (`<= 256`), baked
822    /// into the kernel program.
823    #[must_use]
824    pub const fn num_classes(&self) -> u32 {
825        self.num_classes
826    }
827
828    /// Device packing of each transition target. The dispatcher MUST build the
829    /// kernel with this width so the unpack matches the uploaded table.
830    #[must_use]
831    pub const fn transition_width(&self) -> TransitionWidth {
832        self.transition_width
833    }
834
835    /// Read-only input buffers in `combined_batch_program_buffers` declaration
836    /// order (file_offsets, file_metadata, haystack, transitions,
837    /// output_offsets, output_records, class_maps, segments). The persistent
838    /// pipeline binds inputs positionally in this order.
839    #[must_use]
840    pub fn input_buffers(&self) -> [&GpuBufferHandle; 8] {
841        [
842            &self.offsets,
843            &self.metadata,
844            &self.haystack,
845            &self.transitions,
846            &self.output_offsets,
847            &self.output_records,
848            &self.class_maps,
849            &self.segments,
850        ]
851    }
852
853    /// Writable buffers in declaration order (queue_state, then the hit_ring
854    /// output).
855    #[must_use]
856    pub const fn output_buffers(&self) -> [&GpuBufferHandle; 2] {
857        [&self.queue_state, &self.hit_ring]
858    }
859
860    /// Persistent queue-state buffer (control words).
861    #[must_use]
862    pub const fn queue_state(&self) -> &GpuBufferHandle {
863        &self.queue_state
864    }
865
866    /// Sparse hit-ring buffer.
867    #[must_use]
868    pub const fn hit_ring(&self) -> &GpuBufferHandle {
869        &self.hit_ring
870    }
871}
872
873/// Reject a structurally inconsistent combined automaton at the boundary rather
874/// than uploading tables the kernel would index out of range (Law 10: fail
875/// closed, never silently scan a malformed automaton).
876///
877/// # Panics
878/// Panics when `output_offsets` is empty. The caller rejects an empty offsets table
879/// before reaching the terminator check, so an empty slice here means the two checks
880/// were reordered.
881fn validate_combined_automaton(
882    transitions: &[u32],
883    output_offsets: &[u32],
884    output_records: &[u32],
885    state_count: u32,
886) -> Result<(), PipelineError> {
887    if state_count == 0 {
888        return Err(PipelineError::Backend(
889            "combined automaton has state_count 0. Fix: compile at least one pattern before upload."
890                .to_string(),
891        ));
892    }
893    let state_count_usize = state_count as usize;
894    let expected_transitions = state_count_usize.checked_mul(256).ok_or_else(|| {
895        PipelineError::Backend(
896            "combined transition table size (state_count * 256) overflowed usize. Fix: shard the pattern catalog."
897                .to_string(),
898        )
899    })?;
900    if transitions.len() != expected_transitions {
901        return Err(PipelineError::Backend(format!(
902            "combined transition table has {} words, expected state_count*256 = {expected_transitions}. Fix: pass the dense classic-AC transition table.",
903            transitions.len()
904        )));
905    }
906    if output_offsets.len() != state_count_usize + 1 {
907        return Err(PipelineError::Backend(format!(
908            "combined output_offsets has {} entries, expected state_count+1 = {}. Fix: pass the CSR row-pointer array.",
909            output_offsets.len(),
910            state_count_usize + 1
911        )));
912    }
913    let last_offset = *output_offsets.last().expect("non-empty: checked above") as usize;
914    if last_offset != output_records.len() {
915        return Err(PipelineError::Backend(format!(
916            "combined output_offsets terminator {last_offset} != output_records length {}. Fix: keep the CSR payload and row pointers consistent.",
917            output_records.len()
918        )));
919    }
920    Ok(())
921}
922
923fn accumulate_refresh(
924    report: &mut FileBatchRefreshReport,
925    handle: &mut GpuBufferHandle,
926    device: &wgpu::Device,
927    queue: &wgpu::Queue,
928    bytes: &[u8],
929    usage: wgpu::BufferUsages,
930) -> Result<(), PipelineError> {
931    let refreshed = upload_or_refresh(handle, device, queue, bytes, usage)?;
932    let padded_len = padded_write_len(bytes.len())?;
933    report.bytes_uploaded = report.bytes_uploaded.checked_add(padded_len).ok_or_else(|| {
934        PipelineError::Backend(
935            "batch refresh uploaded-byte accounting overflowed u64. Fix: shard the file batch before GPU upload."
936                .to_string(),
937        )
938    })?;
939    if refreshed {
940        report.resident_allocations += 1;
941        report.refreshed_buffers += 1;
942    } else {
943        report.reused_buffers += 1;
944    }
945    Ok(())
946}
947
948fn upload_or_refresh(
949    handle: &mut GpuBufferHandle,
950    device: &wgpu::Device,
951    queue: &wgpu::Queue,
952    bytes: &[u8],
953    usage: wgpu::BufferUsages,
954) -> Result<bool, PipelineError> {
955    let required_len = padded_write_len(bytes.len())?;
956    if handle.allocation_len() >= required_len
957        && handle
958            .usage()
959            .contains(usage | wgpu::BufferUsages::COPY_DST)
960    {
961        write_padded_prefix(queue, handle.buffer(), bytes)?;
962        Ok(false)
963    } else {
964        *handle = GpuBufferHandle::upload(device, queue, bytes, usage)?;
965        Ok(true)
966    }
967}
968
969fn padded_write_len(len: usize) -> Result<u64, PipelineError> {
970    if len == 0 {
971        return Ok(0);
972    }
973    let normalized = len.max(4);
974    let remainder = normalized % 4;
975    let padded = if remainder == 0 {
976        normalized
977    } else {
978        normalized.checked_add(4 - remainder).ok_or_else(|| {
979            PipelineError::Backend(
980                "refreshed batch buffer length overflows usize while padding to WGPU alignment. Fix: split the batch before upload.".to_string(),
981            )
982        })?
983    };
984    u64::try_from(padded).map_err(|source| {
985        PipelineError::Backend(format!(
986            "refreshed batch buffer length cannot fit u64: {source}. Fix: split the batch before upload."
987        ))
988    })
989}
990
991fn padded_write_len_u64(len: u64) -> Result<u64, PipelineError> {
992    if len == 0 {
993        return Ok(0);
994    }
995    let normalized = len.max(4);
996    let remainder = normalized % 4;
997    if remainder == 0 {
998        return Ok(normalized);
999    }
1000    normalized.checked_add(4 - remainder).ok_or_else(|| {
1001        PipelineError::Backend(
1002            "refreshed batch buffer length overflows u64 while padding to WGPU alignment. Fix: split the batch before upload.".to_string(),
1003        )
1004    })
1005}
1006
1007fn usize_to_u64(value: usize, label: &str) -> Result<u64, PipelineError> {
1008    u64::try_from(value).map_err(|source| {
1009        PipelineError::Backend(format!(
1010            "{label} cannot fit u64: {source}. Fix: split the batch before upload."
1011        ))
1012    })
1013}
1014
1015fn hit_ring_byte_len(hit_capacity: u32) -> Result<u64, PipelineError> {
1016    u64::from(hit_capacity)
1017        .checked_mul(usize_to_u64(HIT_RECORD_WORDS, "hit record word count")?)
1018        .and_then(|words| words.checked_mul(4))
1019        .ok_or_else(|| {
1020            PipelineError::Backend(
1021                "hit-ring allocation byte count overflowed u64. Fix: reduce hit_capacity or shard the batch."
1022                    .to_string(),
1023            )
1024        })
1025}
1026
1027fn write_padded_prefix(
1028    queue: &wgpu::Queue,
1029    buffer: &wgpu::Buffer,
1030    bytes: &[u8],
1031) -> Result<(), PipelineError> {
1032    crate::padded_upload::write_padded_prefix(
1033        queue,
1034        buffer,
1035        bytes,
1036        "padded batch write tail offset",
1037    )
1038    .map_err(|source| PipelineError::Backend(source.to_string()))?;
1039    Ok(())
1040}
1041
1042fn validate_batch_shape(files: &[BatchFile], rule_count: u32) -> Result<(), PipelineError> {
1043    if u32::try_from(files.len()).is_err() {
1044        return Err(PipelineError::QueueFull {
1045            queue: "submission",
1046            fix: "file count exceeds u32::MAX; split the batch into smaller file shards",
1047        });
1048    }
1049    let mut total_bytes = 0u64;
1050    for file in files {
1051        if u32::try_from(file.bytes.len()).is_err() {
1052            return Err(PipelineError::QueueFull {
1053                queue: "submission",
1054                fix: "file size exceeds u32::MAX; split the batch into smaller files before megakernel batching",
1055            });
1056        }
1057        let file_len = usize_to_u64(file.bytes.len(), "batched file byte length")?;
1058        total_bytes = total_bytes
1059            .checked_add(file_len)
1060            .ok_or(PipelineError::QueueFull {
1061                queue: "submission",
1062                fix: "batched haystack length overflowed u64; split the batch into smaller shards",
1063            })?;
1064    }
1065    if total_bytes > u64::from(u32::MAX) {
1066        return Err(PipelineError::QueueFull {
1067            queue: "submission",
1068            fix: "batched haystack exceeds u32::MAX bytes; split the batch into smaller shards",
1069        });
1070    }
1071    validate_work_queue_shape(files.len(), rule_count)
1072}
1073
1074fn dense_queue_len(file_count: usize, rule_count: u32) -> Result<u32, PipelineError> {
1075    let rule_count = usize::try_from(rule_count).map_err(|_| PipelineError::QueueFull {
1076        queue: "submission",
1077        fix: "rule count cannot fit host usize; shard the rule set before dense queue planning",
1078    })?;
1079    let capacity = file_count
1080        .checked_mul(rule_count)
1081        .ok_or(PipelineError::QueueFull {
1082        queue: "submission",
1083        fix: "file_count * rule_count overflowed usize; split the batch or reduce the rule fanout",
1084    })?;
1085    if u32::try_from(capacity).is_err() {
1086        return Err(PipelineError::QueueFull {
1087            queue: "submission",
1088            fix: "work queue length exceeds u32::MAX; split the batch or reduce the rule fanout before allocation",
1089        });
1090    }
1091    if capacity > MAX_BATCH_WORK_ITEMS {
1092        return Err(PipelineError::QueueFull {
1093            queue: "submission",
1094            fix: "work queue length exceeds the device claim protocol; split the file batch or reduce the rule fanout",
1095        });
1096    }
1097    u32::try_from(capacity).map_err(|_| PipelineError::QueueFull {
1098        queue: "submission",
1099        fix: "work queue length exceeds u32::MAX; split the batch or reduce the rule fanout before allocation",
1100    })
1101}
1102
1103fn validate_work_queue_shape(file_count: usize, rule_count: u32) -> Result<(), PipelineError> {
1104    dense_queue_len(file_count, rule_count).map(|_| ())
1105}
1106
1107fn build_metadata_into(
1108    files: &[BatchFile],
1109    metadata: &mut Vec<FileMetadata>,
1110) -> Result<(), PipelineError> {
1111    reserve_batch_vec_len(metadata, files.len(), "file metadata records")?;
1112    if metadata.len() == files.len() {
1113        for (slot, file) in metadata.iter_mut().zip(files) {
1114            *slot = FileMetadata::from_file(file)?;
1115        }
1116    } else {
1117        metadata.clear();
1118        for file in files {
1119            metadata.push(FileMetadata::from_file(file)?);
1120        }
1121    }
1122    Ok(())
1123}
1124
1125#[cfg(test)]
1126fn build_offsets(files: &[BatchFile]) -> Result<Vec<u32>, PipelineError> {
1127    let mut offsets = Vec::new();
1128    build_offsets_into(files, &mut offsets)?;
1129    Ok(offsets)
1130}
1131
1132fn build_offsets_into(files: &[BatchFile], offsets: &mut Vec<u32>) -> Result<(), PipelineError> {
1133    let required = files.len().checked_add(1).ok_or(PipelineError::QueueFull {
1134        queue: "submission",
1135        fix: "file count overflows offset table length; split the batch before upload",
1136    })?;
1137    reserve_batch_vec_len(offsets, required, "file offset table")?;
1138    let stable_len = offsets.len() == required;
1139    if stable_len {
1140        offsets[0] = 0;
1141    } else {
1142        offsets.clear();
1143        offsets.push(0);
1144    }
1145    let mut total = 0u64;
1146    for (index, file) in files.iter().enumerate() {
1147        let file_len = usize_to_u64(file.bytes.len(), "batched file byte length")?;
1148        total = total
1149            .checked_add(file_len)
1150            .ok_or(PipelineError::QueueFull {
1151                queue: "submission",
1152                fix: "batched haystack length overflowed u64; split the batch into smaller shards",
1153            })?;
1154        let offset = u32::try_from(total).map_err(|_| PipelineError::QueueFull {
1155            queue: "submission",
1156            fix: "batched haystack exceeds u32::MAX bytes; split the batch into smaller shards",
1157        })?;
1158        if stable_len {
1159            offsets[index + 1] = offset;
1160        } else {
1161            offsets.push(offset);
1162        }
1163    }
1164    Ok(())
1165}
1166
1167#[cfg(test)]
1168fn flatten_haystack_words(files: &[BatchFile]) -> Result<Vec<u32>, PipelineError> {
1169    let mut words = Vec::new();
1170    flatten_haystack_words_into(files, &mut words)?;
1171    Ok(words)
1172}
1173
1174fn flatten_haystack_words_into(
1175    files: &[BatchFile],
1176    words: &mut Vec<u32>,
1177) -> Result<(), PipelineError> {
1178    let total = files.iter().try_fold(0usize, |acc, file| {
1179        acc.checked_add(file.bytes.len())
1180            .ok_or(PipelineError::QueueFull {
1181                queue: "submission",
1182                fix:
1183                    "batched haystack length overflowed usize; split the batch into smaller shards",
1184            })
1185    })?;
1186    let target_words = total.div_ceil(4).max(1);
1187    reserve_batch_vec_len(words, target_words, "packed haystack words")?;
1188    let stable_len = words.len() == target_words;
1189    if stable_len {
1190        words.fill(0);
1191    } else {
1192        words.clear();
1193    }
1194    let mut word = 0u32;
1195    let mut shift = 0u32;
1196    let mut word_index = 0usize;
1197    for file in files {
1198        pack_bytes_into_words(
1199            &file.bytes,
1200            words,
1201            stable_len,
1202            &mut word_index,
1203            &mut word,
1204            &mut shift,
1205        );
1206    }
1207    if shift != 0 {
1208        write_packed_word(words, stable_len, &mut word_index, word);
1209    }
1210    if word_index == 0 {
1211        write_packed_word(words, stable_len, &mut word_index, 0);
1212    }
1213    Ok(())
1214}
1215
1216fn pack_bytes_into_words(
1217    bytes: &[u8],
1218    words: &mut Vec<u32>,
1219    stable_len: bool,
1220    word_index: &mut usize,
1221    word: &mut u32,
1222    shift: &mut u32,
1223) {
1224    let mut cursor = bytes;
1225    if *shift != 0 {
1226        while *shift != 0 && !cursor.is_empty() {
1227            *word |= u32::from(cursor[0]) << *shift;
1228            *shift += 8;
1229            cursor = &cursor[1..];
1230            if *shift == 32 {
1231                write_packed_word(words, stable_len, word_index, *word);
1232                *word = 0;
1233                *shift = 0;
1234            }
1235        }
1236    }
1237
1238    let mut chunks = cursor.chunks_exact(4);
1239    for chunk in &mut chunks {
1240        write_packed_word(
1241            words,
1242            stable_len,
1243            word_index,
1244            u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]),
1245        );
1246    }
1247    for byte in chunks.remainder() {
1248        *word |= u32::from(*byte) << *shift;
1249        *shift += 8;
1250    }
1251}
1252
1253fn write_packed_word(words: &mut Vec<u32>, stable_len: bool, word_index: &mut usize, word: u32) {
1254    if stable_len {
1255        words[*word_index] = word;
1256    } else {
1257        words.push(word);
1258    }
1259    *word_index += 1;
1260}
1261
1262#[cfg(test)]
1263fn derive_work_triple(
1264    file_metadata: &[FileMetadata],
1265    rule_count: u32,
1266    claim: u32,
1267) -> Result<WorkTriple, PipelineError> {
1268    let queue_len = dense_queue_len(file_metadata.len(), rule_count)?;
1269    if claim >= queue_len || rule_count == 0 {
1270        return Err(PipelineError::QueueFull {
1271            queue: "submission",
1272            fix: "claim exceeds the dense device-derived queue; keep queue length and rule count synchronized",
1273        });
1274    }
1275    let file_idx = claim / rule_count;
1276    let rule_idx = claim % rule_count;
1277    let file_idx_usize = usize::try_from(file_idx).map_err(|_| PipelineError::QueueFull {
1278        queue: "submission",
1279        fix: "derived file index cannot fit host usize; shard the batch before decoding work triples",
1280    })?;
1281    let metadata = file_metadata
1282        .get(file_idx_usize)
1283        .ok_or(PipelineError::QueueFull {
1284            queue: "submission",
1285            fix: "derived file index exceeds metadata length; keep queue length and metadata synchronized",
1286        })?;
1287    Ok(WorkTriple::new(
1288        file_idx,
1289        rule_idx,
1290        metadata.decoded_layer_index,
1291    ))
1292}
1293
1294fn validate_hit_capacity(hit_capacity: u32) -> Result<(), PipelineError> {
1295    if hit_capacity > MAX_BATCH_HIT_CAPACITY {
1296        return Err(PipelineError::QueueFull {
1297            queue: "submission",
1298            fix: "hit capacity exceeds the per-batch sparse ring cap; shard the batch or drain hits across multiple launches",
1299        });
1300    }
1301    Ok(())
1302}
1303
1304fn initial_queue_state(
1305    queue_len: u32,
1306    hit_capacity: u32,
1307    rule_count: u32,
1308) -> [u32; QUEUE_STATE_WORDS] {
1309    // Word order MUST match `queue_state_word::*`:
1310    // [HEAD, QUEUE_LEN, HIT_HEAD, HIT_CAPACITY, DONE_COUNT, RULE_COUNT].
1311    [0, queue_len, 0, hit_capacity, 0, rule_count]
1312}
1313
1314/// Default (non-tuned) window geometry: `seg_len = u32::MAX` and `overlap = 0`,
1315/// which yields exactly ONE segment per file. The resulting `segments` table is
1316/// `[file_idx, 0, 0, file_len]` per file, so the device decode scans each file
1317/// whole from state 0 with no warm-up, byte-for-byte the pre-segmentation
1318/// behavior. Tuning `seg_len` below the file length saturates the device, but is
1319/// only sound once `overlap >= the catalog's longest match span`; that geometry
1320/// is chosen by the caller, never defaulted here.
1321fn default_segmentation(_file_count: usize) -> (u32, u32) {
1322    (u32::MAX, 0)
1323}
1324
1325/// Build the flat device segment table from the per-file metadata at the given
1326/// window geometry. The row order matches [`segmentation::plan_segments`], so the
1327/// device `seg_idx = claim / rule_count` indexes it directly.
1328fn build_segment_table(file_metadata: &[FileMetadata], seg_len: u32, overlap: u32) -> Vec<u32> {
1329    let file_lens: Vec<u32> = file_metadata.iter().map(|meta| meta.size_bytes).collect();
1330    segmentation::segment_table(&file_lens, seg_len, overlap)
1331}
1332
1333/// Work-queue length for a segment table = `segment_count * rule_count`, reusing
1334/// the same overflow/limit guards as the dense path (`dense_queue_len` is the
1335/// `count * rule_count` primitive; here `count` is the segment count, not the
1336/// file count). A zero-length table (every file empty) yields a zero queue.
1337fn segment_queue_len(segment_words: &[u32], rule_count: u32) -> Result<u32, PipelineError> {
1338    let segment_count = segment_words.len() / segmentation::SEGMENT_WORDS;
1339    dense_queue_len(segment_count, rule_count)
1340}
1341
1342fn reserve_batch_vec_len<T>(
1343    vec: &mut Vec<T>,
1344    target_len: usize,
1345    label: &'static str,
1346) -> Result<(), PipelineError> {
1347    reserve_vec_exact_for_len(
1348        vec,
1349        target_len,
1350        "megakernel FileBatch staging",
1351        label,
1352        "split the file batch or reduce rule fanout before upload",
1353    )
1354    .map_err(|error| PipelineError::Backend(error.to_string()))
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    #[test]
1362    fn offsets_are_prefix_sums() {
1363        let files = vec![
1364            BatchFile::new(1, 0, b"ab".to_vec()),
1365            BatchFile::new(2, 3, b"cdef".to_vec()),
1366        ];
1367        assert_eq!(build_offsets(&files).unwrap(), vec![0, 2, 6]);
1368    }
1369
1370    #[test]
1371    fn haystack_flattening_preserves_cross_file_byte_order() {
1372        let files = vec![
1373            BatchFile::new(1, 0, vec![1, 2, 3]),
1374            BatchFile::new(2, 0, vec![4, 5, 6, 7, 8]),
1375            BatchFile::new(3, 0, vec![9]),
1376        ];
1377
1378        let words = flatten_haystack_words(&files).unwrap();
1379
1380        assert_eq!(
1381            words,
1382            vec![
1383                u32::from_le_bytes([1, 2, 3, 4]),
1384                u32::from_le_bytes([5, 6, 7, 8]),
1385                u32::from_le_bytes([9, 0, 0, 0]),
1386            ]
1387        );
1388    }
1389
1390    #[test]
1391    fn device_schedule_derives_files_x_rules_without_materialized_queue() {
1392        let metadata = vec![
1393            FileMetadata {
1394                path_hash_lo: 1,
1395                path_hash_hi: 0,
1396                size_bytes: 2,
1397                decoded_layer_index: 4,
1398            },
1399            FileMetadata {
1400                path_hash_lo: 2,
1401                path_hash_hi: 0,
1402                size_bytes: 3,
1403                decoded_layer_index: 9,
1404            },
1405        ];
1406        let queue = (0..dense_queue_len(metadata.len(), 2).unwrap())
1407            .map(|claim| derive_work_triple(&metadata, 2, claim).unwrap())
1408            .collect::<Vec<_>>();
1409        assert_eq!(
1410            queue,
1411            vec![
1412                WorkTriple::new(0, 0, 4),
1413                WorkTriple::new(0, 1, 4),
1414                WorkTriple::new(1, 0, 9),
1415                WorkTriple::new(1, 1, 9),
1416            ]
1417        );
1418    }
1419
1420    #[test]
1421    fn work_queue_rejects_u32_overflow_before_allocation() {
1422        let metadata = vec![
1423            FileMetadata {
1424                path_hash_lo: 1,
1425                path_hash_hi: 0,
1426                size_bytes: 1,
1427                decoded_layer_index: 0,
1428            },
1429            FileMetadata {
1430                path_hash_lo: 2,
1431                path_hash_hi: 0,
1432                size_bytes: 1,
1433                decoded_layer_index: 0,
1434            },
1435        ];
1436        let err = dense_queue_len(metadata.len(), u32::MAX).expect_err(
1437            "Fix: queue fanout exceeding u32 protocol must be rejected before allocation",
1438        );
1439        assert!(matches!(err, PipelineError::QueueFull { .. }));
1440    }
1441
1442    #[test]
1443    fn device_schedule_accepts_batches_above_legacy_host_queue_cap_without_allocating() {
1444        let metadata = vec![
1445            FileMetadata {
1446                path_hash_lo: 1,
1447                path_hash_hi: 0,
1448                size_bytes: 1,
1449                decoded_layer_index: 0,
1450            };
1451            2
1452        ];
1453        const LEGACY_HOST_WORK_QUEUE_CAP: usize = 16 * 1024 * 1024;
1454        let rule_count = u32::try_from(LEGACY_HOST_WORK_QUEUE_CAP / metadata.len() + 1).unwrap();
1455        let queue_len = dense_queue_len(metadata.len(), rule_count)
1456            .expect("Fix: device-derived scheduling must not retain the old host allocation cap");
1457        assert!(
1458            queue_len as usize > LEGACY_HOST_WORK_QUEUE_CAP,
1459            "dense scheduling must scale past the removed host Vec<WorkTriple> limit"
1460        );
1461    }
1462
1463    #[test]
1464    fn hit_capacity_rejects_allocation_cap() {
1465        let err = validate_hit_capacity(MAX_BATCH_HIT_CAPACITY + 1)
1466            .expect_err("oversized hit ring must reject before GPU allocation");
1467        assert!(matches!(err, PipelineError::QueueFull { .. }));
1468    }
1469
1470    #[test]
1471    fn refresh_reuses_host_and_gpu_storage_when_shape_fits() {
1472        let backend = crate::WgpuBackend::new().expect(
1473            "Fix: live WGPU backend required for FileBatch refresh reuse contract; missing GPU is a configuration bug.",
1474        );
1475        let first = vec![
1476            BatchFile::new(1, 0, b"abcdefgh".to_vec()),
1477            BatchFile::new(2, 1, b"ijklmnop".to_vec()),
1478        ];
1479        let second = vec![BatchFile::new(3, 2, b"xyz".to_vec())];
1480        let mut batch = FileBatch::upload(backend.device_queue(), &first, 4, 1024)
1481            .expect("Fix: initial FileBatch upload must succeed");
1482        let metadata_ptr = batch.file_metadata.as_ptr();
1483        let offsets_ptr = batch.file_offsets.as_ptr();
1484        let haystack_words_ptr = batch.haystack_words.as_ptr();
1485        let haystack_id = batch.haystack.allocation_identity();
1486        let offsets_id = batch.offsets.allocation_identity();
1487        let metadata_id = batch.metadata.allocation_identity();
1488        let queue_state_id = batch.queue_state.allocation_identity();
1489        let hit_ring_id = batch.hit_ring.allocation_identity();
1490
1491        let refresh_report = batch
1492            .refresh_with_report(&second, 2, 512)
1493            .expect("Fix: smaller FileBatch refresh must succeed in place");
1494
1495        assert_eq!(batch.file_metadata.as_ptr(), metadata_ptr);
1496        assert_eq!(batch.file_offsets.as_ptr(), offsets_ptr);
1497        assert_eq!(batch.haystack_words.as_ptr(), haystack_words_ptr);
1498        assert_eq!(batch.haystack.allocation_identity(), haystack_id);
1499        assert_eq!(batch.offsets.allocation_identity(), offsets_id);
1500        assert_eq!(batch.metadata.allocation_identity(), metadata_id);
1501        assert_eq!(batch.queue_state.allocation_identity(), queue_state_id);
1502        assert_eq!(batch.hit_ring.allocation_identity(), hit_ring_id);
1503        assert_eq!(batch.file_count(), 1);
1504        assert_eq!(batch.queue_len(), 2);
1505        assert!(
1506            batch.host_work_items().is_empty(),
1507            "dense megakernel batches must not retain file_count * rule_count host triples"
1508        );
1509        assert_eq!(batch.hit_capacity(), 512);
1510        assert_eq!(
1511            refresh_report.resident_allocations, 0,
1512            "smaller refresh must reuse every resident allocation"
1513        );
1514        assert_eq!(
1515            refresh_report.refreshed_buffers, 0,
1516            "smaller refresh must not replace resident input buffers"
1517        );
1518        assert_eq!(
1519            refresh_report.reused_buffers, 6,
1520            "refresh must account for five refreshed inputs (haystack, offsets, metadata, \
1521             segments, queue_state) plus hit ring reuse"
1522        );
1523        assert!(
1524            refresh_report.bytes_uploaded > 0,
1525            "refresh telemetry must report host-to-device logical prefix writes"
1526        );
1527
1528        let config = crate::megakernel::BatchDispatchConfig {
1529            workgroup_size_x: 64,
1530            worker_groups: 4,
1531            hit_capacity: 512,
1532            timeout: std::time::Duration::from_secs(10),
1533            ..Default::default()
1534        };
1535        let mut dispatcher = crate::megakernel::BatchDispatcher::new(backend, config)
1536            .expect("Fix: live batch dispatcher must compile after FileBatch refresh");
1537        let rules = vec![
1538            vyre_runtime::megakernel::BatchRuleProgram::new(0, vec![0; 256], vec![1], 1)
1539                .expect("Fix: accepting rule 0 must be valid"),
1540            vyre_runtime::megakernel::BatchRuleProgram::new(1, vec![0; 256], vec![1], 1)
1541                .expect("Fix: accepting rule 1 must be valid"),
1542        ];
1543        let mut hits = Vec::new();
1544        let report = dispatcher
1545            .dispatch_into(&batch, &rules, &mut hits)
1546            .expect("Fix: refreshed FileBatch must dispatch");
1547        assert_eq!(
1548            report.hit_count, 6,
1549            "refreshed batch must scan only the 3 refreshed bytes across 2 rules, not stale tail bytes"
1550        );
1551    }
1552
1553    #[test]
1554    fn set_segmentation_preserves_hits_vs_whole_file() {
1555        // GPU PARITY: tiling a file into overlapping windows (`set_segmentation`)
1556        // must produce the EXACT same hit set as the whole-file scan. Uses an
1557        // accept-every rule (one state, accept[0]=1) so every byte offset is a
1558        // hit, the hit set is the offset tiling itself, so any double-count,
1559        // gap, or warm-up leak shows up directly. overlap=8 exercises the
1560        // emit-guard: warm-up bytes (`byte_pos < emit_start`) must advance state
1561        // but NEVER emit. dfa_sync_distance of a 1-state DFA is 0, so overlap=8 is
1562        // amply sound here.
1563        let backend = crate::WgpuBackend::new()
1564            .expect("Fix: live WGPU backend required for the segmentation GPU-parity contract.");
1565        let content: Vec<u8> = (0..600u32).map(|i| (i % 251) as u8).collect();
1566        let files = vec![BatchFile::new(7, 0, content)];
1567        let mut batch = FileBatch::upload(backend.device_queue(), &files, 1, 2048)
1568            .expect("Fix: FileBatch upload must succeed");
1569
1570        let config = crate::megakernel::BatchDispatchConfig {
1571            workgroup_size_x: 64,
1572            worker_groups: 8,
1573            hit_capacity: 2048,
1574            timeout: std::time::Duration::from_secs(10),
1575            ..Default::default()
1576        };
1577        let mut dispatcher = crate::megakernel::BatchDispatcher::new(backend, config)
1578            .expect("Fix: live batch dispatcher must compile");
1579        let rules =
1580            vec![
1581                vyre_runtime::megakernel::BatchRuleProgram::new(0, vec![0; 256], vec![1], 1)
1582                    .expect("Fix: accept-every rule must be valid"),
1583            ];
1584
1585        // Whole-file scan (one segment per file): expect one hit per byte offset.
1586        assert_eq!(
1587            batch.queue_len(),
1588            1,
1589            "default geometry is one work item (1 file × 1 rule)"
1590        );
1591        let mut whole = Vec::new();
1592        dispatcher
1593            .dispatch_into(&batch, &rules, &mut whole)
1594            .expect("Fix: whole-file dispatch must succeed");
1595        let whole_set: std::collections::BTreeSet<(u32, u32)> =
1596            whole.iter().map(|h| (h.rule_idx, h.match_offset)).collect();
1597        assert_eq!(
1598            whole_set.len(),
1599            600,
1600            "accept-every rule hits every one of 600 offsets"
1601        );
1602        assert_eq!(*whole_set.iter().next().unwrap(), (0, 0));
1603        assert_eq!(*whole_set.iter().next_back().unwrap(), (0, 599));
1604
1605        // Segment into 5 windows (ceil(600/128)) with 8 bytes of warm-up.
1606        batch
1607            .set_segmentation(128, 8)
1608            .expect("Fix: set_segmentation must succeed");
1609        assert_eq!(
1610            batch.queue_len(),
1611            5,
1612            "600 bytes at seg_len=128 ⇒ 5 segments × 1 rule = 5 work items"
1613        );
1614        let mut segmented = Vec::new();
1615        dispatcher
1616            .dispatch_into(&batch, &rules, &mut segmented)
1617            .expect("Fix: segmented dispatch must succeed");
1618        let segmented_set: std::collections::BTreeSet<(u32, u32)> = segmented
1619            .iter()
1620            .map(|h| (h.rule_idx, h.match_offset))
1621            .collect();
1622
1623        assert_eq!(
1624            segmented_set, whole_set,
1625            "segmented scan must produce the identical (rule, offset) hit set as the whole-file scan"
1626        );
1627        // No double counting from the overlapping warm-up regions.
1628        assert_eq!(
1629            segmented.len(),
1630            whole.len(),
1631            "segmented scan must not duplicate hits across window warm-up overlaps"
1632        );
1633    }
1634
1635    #[test]
1636    fn refresh_reused_buffers_write_only_padded_logical_prefix() {
1637        let src = include_str!("batch.rs");
1638        let production = src
1639            .split("\n#[cfg(test)]\nmod tests")
1640            .next()
1641            .expect("Fix: FileBatch production section should precede tests");
1642        let refresh_body = src
1643            .split("pub fn refresh(")
1644            .nth(1)
1645            .and_then(|tail| tail.split("pub fn reset_queue_state").next())
1646            .expect("Fix: FileBatch::refresh body must be discoverable");
1647        let reused_write_body = src
1648            .split("fn write_padded_prefix(")
1649            .nth(1)
1650            .and_then(|tail| tail.split("fn validate_batch_shape").next())
1651            .expect("Fix: write_padded_prefix body must be discoverable");
1652
1653        assert!(
1654            refresh_body.contains("accumulate_refresh"),
1655            "FileBatch::refresh must route resident inputs through telemetry-aware reusable buffer refresh"
1656        );
1657        assert!(
1658            refresh_body.contains("refresh_with_report"),
1659            "FileBatch::refresh must preserve the telemetry-capable refresh path"
1660        );
1661        assert!(
1662            reused_write_body.contains("crate::padded_upload::write_padded_prefix"),
1663            "reused FileBatch buffers must use the shared padded-prefix writer"
1664        );
1665        assert!(
1666            !reused_write_body.contains("allocation_len"),
1667            "reused FileBatch buffers must not zero-fill the full old allocation on smaller refreshes"
1668        );
1669        assert!(
1670            !production.contains("Vec::with_capacity"),
1671            "Fix: FileBatch upload/refresh staging must not use infallible capacity constructors."
1672        );
1673        assert!(
1674            !production.contains(".reserve_exact("),
1675            "Fix: FileBatch upload/refresh staging must route reservations through the shared fallible helper."
1676        );
1677        assert!(
1678            production.contains("reserve_batch_vec_len"),
1679            "Fix: FileBatch staging should have one shared target-length reservation adapter."
1680        );
1681    }
1682}