Skip to main content

par2_rs/
repair.rs

1//! PAR2 repair orchestration using Reed-Solomon decoding over GF(2^16).
2//!
3//! This module implements the full repair pipeline:
4//! 1. Analyze verification results to identify missing/damaged slices
5//! 2. Build a repair plan (decode matrix from Gaussian elimination)
6//! 3. Execute repair by reading recovery data, XOR-ing out known contributions,
7//!    and multiplying by the decode matrix to reconstruct missing slices
8//! 4. Write repaired data back to files
9
10use rayon::prelude::*;
11use std::collections::{HashMap, HashSet, VecDeque};
12use std::fs::File;
13use std::io::{self, Read, Seek, SeekFrom};
14use std::mem::MaybeUninit;
15use std::ops::ControlFlow;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18#[cfg(test)]
19use std::sync::atomic::AtomicU32;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::time::{Duration, Instant};
22use tracing::{debug, info, warn};
23
24use crate::cpu_repair_controller::{
25    ControllerAddResult, ControllerAddStatus, ControllerExecutionEvent, ControllerExecutionTrace,
26    ControllerFailurePhase, ControllerLayout, ControllerLifecycle, CpuControllerPlan,
27    CpuMethodContract, CpuPrefetch, InputBatch,
28};
29use crate::error::{Par2Error, Result};
30use crate::gf;
31use crate::matrix;
32use crate::par2_set::Par2FileSet;
33use crate::types::{
34    CancellationToken, FileId, MAX_SLICES_PER_FILE, MAX_TOTAL_INPUT_SLICES, ProgressCallback,
35    ProgressPhase, ProgressStage, ProgressUpdate,
36};
37use crate::verify::{FileAccess, FileRangeReader, Repairability, VerificationResult};
38
39/// Default repair workspace budget. It pays for the controller's streamed
40/// data chunk and for whatever persistent state the selected arithmetic
41/// method keeps, so it also sets how many passes a heavily damaged set costs:
42/// at 64 MiB a 32768-slice set missing thousands of slices was down to a chunk
43/// of a few kilobytes, and the XOR-JIT tier's compile-once codebook — the
44/// shape that tier was tuned for — could not fit beside the data buffers at
45/// all.
46pub(crate) const DEFAULT_REPAIR_MEMORY_LIMIT: usize = 128 * 1024 * 1024;
47/// The decode matrix is a transient planning workspace whose size is set by
48/// the damage (missing^2 + missing*total words), not by streaming buffer
49/// tuning. Give it its own budget floor so tight slice-buffer limits do not
50/// refuse legitimately repairable sets; an explicitly larger `memory_limit`
51/// raises it further. Bounded in practice by the 32768-slice spec cap.
52const MATRIX_WORKSPACE_BUDGET_FLOOR: usize = 1024 * 1024 * 1024;
53const XOR_OUT_PAR_CHUNK: usize = 16;
54const CPU_CONTROLLER_BUDGET_INPUTS: usize = 12;
55
56/// A plan for repairing missing/damaged slices.
57#[derive(Debug, Clone)]
58pub struct RepairPlan {
59    /// The missing input slices as (FileId, slice_index) pairs.
60    pub missing_slices: Vec<(FileId, u32)>,
61    /// Global indices of missing slices in the concatenated input slice ordering.
62    pub missing_global_indices: Vec<usize>,
63    /// Global indices of source slices that are already available and will be read as inputs.
64    pub available_input_global_indices: Vec<usize>,
65    /// Which recovery block exponents to use (one per missing slice).
66    pub recovery_exponents: Vec<u32>,
67    /// The inverted decode matrix rows (each row has `missing_slices.len()` entries).
68    pub decode_matrix: matrix::Matrix,
69    /// Full repair coefficients with columns ordered as
70    /// `available_input_global_indices` followed by `recovery_exponents`.
71    pub input_factors: matrix::Matrix,
72    /// The PAR2 slice size in bytes.
73    pub slice_size: u64,
74    /// Constants for all input slices (needed for XOR-out step).
75    pub constants: Vec<u16>,
76    /// Total number of input slices across all files.
77    pub total_input_slices: usize,
78    /// Mapping from global slice index to (FileId, local_slice_index).
79    pub global_to_file: Vec<(FileId, u32)>,
80}
81
82/// Plan a repair operation based on verification results.
83///
84/// Examines the verification result to find missing/damaged slices, selects
85/// recovery blocks to use, and builds the decode matrix via Gaussian elimination.
86pub fn plan_repair(
87    par2_set: &Par2FileSet,
88    verification: &VerificationResult,
89) -> Result<RepairPlan> {
90    plan_repair_with_memory_limit(par2_set, verification, Some(DEFAULT_REPAIR_MEMORY_LIMIT))
91}
92
93/// Plan a repair operation using an explicit repair workspace memory limit.
94pub fn plan_repair_with_memory_limit(
95    par2_set: &Par2FileSet,
96    verification: &VerificationResult,
97    memory_limit: Option<usize>,
98) -> Result<RepairPlan> {
99    // Check repairability.
100    match &verification.repairable {
101        Repairability::NotNeeded => {
102            return Err(Par2Error::ReedSolomonError {
103                reason: "no repair needed".to_string(),
104            });
105        }
106        Repairability::Insufficient {
107            blocks_needed,
108            blocks_available,
109            deficit,
110        } => {
111            return Err(Par2Error::InsufficientRecoveryData {
112                needed: *blocks_needed,
113                available: *blocks_available,
114                deficit: *deficit,
115            });
116        }
117        Repairability::ResourceLimited { reason } => {
118            return Err(Par2Error::ResourceLimitExceeded {
119                reason: format!("PAR2 verification is resource-limited: {reason}"),
120            });
121        }
122        Repairability::Repairable { .. } => {}
123    }
124
125    // Build the global slice index mapping.
126    // Global ordering: files in order of recovery_file_ids, slices in order within each file.
127    let mut global_to_file: Vec<(FileId, u32)> = Vec::new();
128    for file_id in &par2_set.recovery_file_ids {
129        if let Some(desc) = par2_set.file_description(file_id) {
130            let slice_count =
131                usize::try_from(par2_set.slice_count_for_file(desc.length)).map_err(|_| {
132                    Par2Error::ResourceLimitExceeded {
133                        reason: format!(
134                            "file {} has more than {MAX_SLICES_PER_FILE} addressable PAR2 slices",
135                            desc.filename
136                        ),
137                    }
138                })?;
139            if slice_count > MAX_SLICES_PER_FILE {
140                return Err(Par2Error::ResourceLimitExceeded {
141                    reason: format!(
142                        "file {} has {slice_count} PAR2 slices; max is {MAX_SLICES_PER_FILE}",
143                        desc.filename
144                    ),
145                });
146            }
147            for s in 0..slice_count {
148                global_to_file.push((*file_id, s as u32));
149            }
150        }
151    }
152    let total_input_slices = global_to_file.len();
153    if total_input_slices > MAX_TOTAL_INPUT_SLICES {
154        return Err(Par2Error::ResourceLimitExceeded {
155            reason: format!(
156                "recovery set has {total_input_slices} input slices; PAR2 supports at most {MAX_TOTAL_INPUT_SLICES}"
157            ),
158        });
159    }
160
161    // Identify missing slices (global indices).
162    let mut missing_slices: Vec<(FileId, u32)> = Vec::new();
163    let mut missing_global_indices: Vec<usize> = Vec::new();
164
165    let mut global_idx = 0usize;
166    for file_id in &par2_set.recovery_file_ids {
167        let desc = match par2_set.file_description(file_id) {
168            Some(d) => d,
169            None => continue,
170        };
171        let slice_count =
172            usize::try_from(par2_set.slice_count_for_file(desc.length)).map_err(|_| {
173                Par2Error::ResourceLimitExceeded {
174                    reason: format!(
175                        "file {} has more than {MAX_SLICES_PER_FILE} addressable PAR2 slices",
176                        desc.filename
177                    ),
178                }
179            })?;
180        if slice_count > MAX_SLICES_PER_FILE {
181            return Err(Par2Error::ResourceLimitExceeded {
182                reason: format!(
183                    "file {} has {slice_count} PAR2 slices; max is {MAX_SLICES_PER_FILE}",
184                    desc.filename
185                ),
186            });
187        }
188
189        // Find this file's verification result.
190        let file_verif = verification.files.iter().find(|fv| fv.file_id == *file_id);
191
192        for s in 0..slice_count {
193            let is_valid = file_verif
194                .map(|fv| fv.valid_slices.get(s).copied().unwrap_or(false))
195                .unwrap_or(false);
196
197            if !is_valid {
198                missing_slices.push((*file_id, s as u32));
199                missing_global_indices.push(global_idx + s);
200            }
201        }
202        global_idx += slice_count;
203    }
204
205    let missing_count = missing_slices.len();
206    debug!("repair: {missing_count} missing slices identified");
207
208    // Select recovery exponents. Try the first N available; if the decode
209    // matrix is singular (bad recovery block), skip that exponent and retry
210    // with the next available one.
211    let mut all_exponents: Vec<u32> = par2_set.recovery_slices.keys().copied().collect();
212    all_exponents.sort_unstable();
213
214    if all_exponents.len() < missing_count {
215        return Err(Par2Error::InsufficientRecoveryData {
216            needed: missing_count as u32,
217            available: all_exponents.len() as u32,
218            deficit: (missing_count - all_exponents.len()) as u32,
219        });
220    }
221    if let Some(reason) =
222        repair_matrix_limit_reason(total_input_slices, missing_count, memory_limit)
223    {
224        return Err(Par2Error::ResourceLimitExceeded { reason });
225    }
226
227    // Compute constants for all input slices.
228    let constants = gf::input_slice_constants(total_input_slices);
229    let missing_set: HashSet<usize> = missing_global_indices.iter().copied().collect();
230    let available_input_global_indices: Vec<usize> = (0..total_input_slices)
231        .filter(|global_idx| !missing_set.contains(global_idx))
232        .collect();
233
234    // Try building the decode matrix, skipping corrupt or singular recovery
235    // blocks. Payload validation is lazy: only selected blocks are hashed,
236    // each at most once, so undamaged repairs never pay for unused volumes.
237    let mut skip_set: HashSet<usize> = HashSet::new();
238    let mut validated_exponents: HashMap<u32, bool> = HashMap::new();
239    let (recovery_exponents, input_factors, decode) = loop {
240        let selected_indices: Vec<usize> = all_exponents
241            .iter()
242            .enumerate()
243            .filter(|(i, _)| !skip_set.contains(i))
244            .map(|(i, _)| i)
245            .take(missing_count)
246            .collect();
247        let selected: Vec<u32> = selected_indices
248            .iter()
249            .map(|&idx| all_exponents[idx])
250            .collect();
251
252        if selected.len() < missing_count {
253            return Err(Par2Error::InsufficientRecoveryData {
254                needed: missing_count as u32,
255                available: selected.len() as u32,
256                deficit: (missing_count - selected.len()) as u32,
257            });
258        }
259
260        let mut corrupt_selection = None;
261        for (position, &exponent) in selected.iter().enumerate() {
262            let valid = *validated_exponents.entry(exponent).or_insert_with(|| {
263                let slice = &par2_set.recovery_slices[&exponent];
264                match slice
265                    .data
266                    .validate_packet_hash(par2_set.recovery_set_id.as_bytes(), exponent)
267                {
268                    Ok(valid) => {
269                        if !valid {
270                            warn!(
271                                "recovery block exponent {exponent} failed packet hash validation, skipping"
272                            );
273                        }
274                        valid
275                    }
276                    Err(error) => {
277                        warn!("recovery block exponent {exponent} is unreadable ({error}), skipping");
278                        false
279                    }
280                }
281            });
282            if !valid {
283                corrupt_selection = Some(selected_indices[position]);
284                break;
285            }
286        }
287        if let Some(skip_idx) = corrupt_selection {
288            skip_set.insert(skip_idx);
289            continue;
290        }
291
292        match matrix::build_repair_matrix_with_bad_row(
293            &available_input_global_indices,
294            &missing_global_indices,
295            &selected,
296            &constants,
297        ) {
298            Ok((input_factors, decode)) => break (selected, input_factors, decode),
299            Err(matrix_error) => {
300                let mut skip_idx = matrix_error
301                    .bad_row
302                    .and_then(|row| selected_indices.get(row).copied());
303                if skip_idx.is_none() {
304                    for candidate_idx in &selected_indices {
305                        let trial: Vec<u32> = all_exponents
306                            .iter()
307                            .enumerate()
308                            .filter(|(idx, _)| !skip_set.contains(idx) && idx != candidate_idx)
309                            .map(|(_, &exponent)| exponent)
310                            .take(missing_count)
311                            .collect();
312                        if trial.len() < missing_count {
313                            continue;
314                        }
315                        if matrix::build_repair_matrix_with_bad_row(
316                            &available_input_global_indices,
317                            &missing_global_indices,
318                            &trial,
319                            &constants,
320                        )
321                        .is_ok()
322                        {
323                            skip_idx = Some(*candidate_idx);
324                            break;
325                        }
326                    }
327                }
328                let skip_idx = skip_idx.unwrap_or_else(|| {
329                    *selected_indices
330                        .last()
331                        .expect("singular repair selection must contain at least one row")
332                });
333                warn!(
334                    "recovery exponent {} produced singular matrix, skipping",
335                    all_exponents[skip_idx]
336                );
337                skip_set.insert(skip_idx);
338            }
339        }
340    };
341
342    info!(
343        "repair plan: {} missing slices, {} recovery blocks selected",
344        missing_count,
345        recovery_exponents.len()
346    );
347
348    Ok(RepairPlan {
349        missing_slices,
350        missing_global_indices,
351        available_input_global_indices,
352        recovery_exponents,
353        decode_matrix: decode,
354        input_factors,
355        slice_size: par2_set.slice_size,
356        constants,
357        total_input_slices,
358        global_to_file,
359    })
360}
361
362pub(crate) fn repair_matrix_resource_limit_reason(
363    par2_set: &Par2FileSet,
364    verification: &VerificationResult,
365    memory_limit: Option<usize>,
366) -> Result<Option<String>> {
367    if !matches!(verification.repairable, Repairability::Repairable { .. }) {
368        return Ok(None);
369    }
370
371    let total_input_slices = total_input_slices_for_set(par2_set)?;
372    let missing_count = verification.total_missing_blocks as usize;
373    Ok(repair_matrix_limit_reason(
374        total_input_slices,
375        missing_count,
376        memory_limit,
377    ))
378}
379
380/// Options controlling repair execution.
381pub struct RepairOptions {
382    /// If set, repair will check this token and stop early if cancelled.
383    pub cancel: Option<CancellationToken>,
384    /// If set, called with progress updates during repair.
385    pub progress: Option<ProgressCallback>,
386    /// Maximum transient repair workspace size in bytes.
387    ///
388    /// The CPU repair controller sizes its streamed chunks from this budget.
389    /// Caller-provided solvers supplied through [`execute_repair_with_solver`]
390    /// retain their explicit in-memory contract.
391    ///
392    /// If `None`, the crate default bounded repair budget is used.
393    pub memory_limit: Option<usize>,
394}
395
396impl Default for RepairOptions {
397    fn default() -> Self {
398        Self {
399            cancel: None,
400            progress: None,
401            memory_limit: Some(DEFAULT_REPAIR_MEMORY_LIMIT),
402        }
403    }
404}
405
406#[derive(Clone, Copy)]
407struct FactorIndex {
408    factor: u16,
409    input_idx: u16,
410}
411
412#[derive(Clone, Debug)]
413pub(crate) struct RepairWriteTarget {
414    pub(crate) file_id: FileId,
415    pub(crate) filename: String,
416    pub(crate) offset: u64,
417    pub(crate) file_end: u64,
418}
419
420pub(crate) fn check_cancel(options: &RepairOptions) -> Result<()> {
421    if let Some(ref cancel) = options.cancel
422        && cancel.is_cancelled()
423    {
424        return Err(Par2Error::Cancelled);
425    }
426    Ok(())
427}
428
429fn recv_with_cancel<T>(
430    receiver: &std::sync::mpsc::Receiver<T>,
431    cancel: Option<&CancellationToken>,
432    reason: &'static str,
433) -> Result<T> {
434    loop {
435        match receiver.recv_timeout(Duration::from_millis(20)) {
436            Ok(value) => return Ok(value),
437            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
438                if cancel.is_some_and(|token| token.is_cancelled()) {
439                    return Err(Par2Error::Cancelled);
440                }
441            }
442            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
443                return Err(Par2Error::ReedSolomonError {
444                    reason: reason.to_string(),
445                });
446            }
447        }
448    }
449}
450
451fn estimated_repair_matrix_bytes(total_inputs: usize, missing_rows: usize) -> usize {
452    let working_words = missing_rows
453        .saturating_mul(missing_rows)
454        .saturating_add(missing_rows.saturating_mul(total_inputs));
455    working_words.saturating_mul(std::mem::size_of::<u16>())
456}
457
458fn repair_memory_limit_bytes(memory_limit: Option<usize>) -> usize {
459    memory_limit.unwrap_or(DEFAULT_REPAIR_MEMORY_LIMIT)
460}
461
462fn repair_matrix_limit_reason(
463    total_input_slices: usize,
464    missing_count: usize,
465    memory_limit: Option<usize>,
466) -> Option<String> {
467    if total_input_slices > MAX_TOTAL_INPUT_SLICES {
468        return Some(format!(
469            "recovery set has {total_input_slices} input slices; PAR2 supports at most {MAX_TOTAL_INPUT_SLICES}"
470        ));
471    }
472    let estimated = estimated_repair_matrix_bytes(total_input_slices, missing_count);
473    let limit = repair_memory_limit_bytes(memory_limit).max(MATRIX_WORKSPACE_BUDGET_FLOOR);
474    (estimated > limit).then(|| {
475        format!(
476            "repair matrix for {missing_count} missing slices would require {estimated} bytes, exceeding the {limit} byte matrix workspace budget"
477        )
478    })
479}
480
481fn total_input_slices_for_set(par2_set: &Par2FileSet) -> Result<usize> {
482    let mut total = 0usize;
483    for file_id in &par2_set.recovery_file_ids {
484        let Some(desc) = par2_set.file_description(file_id) else {
485            continue;
486        };
487        let slice_count =
488            usize::try_from(par2_set.slice_count_for_file(desc.length)).map_err(|_| {
489                Par2Error::ResourceLimitExceeded {
490                    reason: format!(
491                        "file {} has more than {MAX_SLICES_PER_FILE} addressable PAR2 slices",
492                        desc.filename
493                    ),
494                }
495            })?;
496        if slice_count > MAX_SLICES_PER_FILE {
497            return Err(Par2Error::ResourceLimitExceeded {
498                reason: format!(
499                    "file {} has {slice_count} PAR2 slices; max is {MAX_SLICES_PER_FILE}",
500                    desc.filename
501                ),
502            });
503        }
504        total = total.saturating_add(slice_count);
505    }
506    Ok(total)
507}
508
509fn cpu_controller_plan(
510    current_slice_size: usize,
511    output_count: usize,
512    worker_count: usize,
513    #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))] method: CpuMethodContract,
514    allocated_staging_width: usize,
515) -> CpuControllerPlan {
516    CpuControllerPlan::new_with_input_grouping_and_staging_width(
517        current_slice_size,
518        output_count,
519        worker_count,
520        method,
521        method.input_grouping(),
522        allocated_staging_width,
523    )
524}
525
526/// Largest chunk, in 16-bit words, whose controller allocation for `method`
527/// fits `controller_budget`, capped at the slice's `word_count`. `None` when
528/// even a single word does not fit.
529///
530/// `ControllerBufferAccounting` is one physical row length times a fixed row
531/// count, and that row length only ever grows with the chunk, so the fit
532/// predicate is monotone and a binary search lands on the exact largest
533/// chunk. Halving instead leaves up to half the limit unspent, and the chunk
534/// is what sets the pass count over the sources.
535fn largest_fitting_chunk_words(
536    word_count: usize,
537    output_count: usize,
538    workers: usize,
539    method: CpuMethodContract,
540    allocated_staging_width: usize,
541    controller_budget: usize,
542) -> Option<usize> {
543    let fits = |chunk_words: usize| {
544        cpu_controller_plan(
545            chunk_words.saturating_mul(2),
546            output_count,
547            workers,
548            method,
549            allocated_staging_width,
550        )
551        .buffer_accounting()
552        .total_bytes
553            <= controller_budget
554    };
555    let word_count = word_count.max(1);
556    if !fits(1) {
557        return None;
558    }
559    if fits(word_count) {
560        return Some(word_count);
561    }
562    // `low` always fits, `high` never does. The layout rounds the surviving
563    // chunk up to the method's stride itself, so the answer needs no further
564    // granule rounding for `CpuControllerPlan` to hold its alignment
565    // invariants.
566    let (mut low, mut high) = (1usize, word_count);
567    while high - low > 1 {
568        let mid = low + (high - low) / 2;
569        if fits(mid) {
570            low = mid;
571        } else {
572            high = mid;
573        }
574    }
575    Some(low)
576}
577
578fn controller_execution_parameters(
579    plan: &RepairPlan,
580    options: &RepairOptions,
581    method: CpuMethodContract,
582    allocated_staging_width: usize,
583    persistent_bytes: usize,
584    workers: usize,
585) -> Result<(usize, usize, CpuControllerPlan)> {
586    let word_count = (plan.slice_size as usize / 2).max(1);
587    let limit = options.memory_limit.unwrap_or(DEFAULT_REPAIR_MEMORY_LIMIT);
588    let output_count = plan.missing_slices.len();
589    let controller_budget = limit.checked_sub(persistent_bytes).ok_or_else(|| {
590        Par2Error::ResourceLimitExceeded {
591            reason: format!(
592                "persistent CPU repair state needs {persistent_bytes} bytes, exceeding the {limit} byte memory limit"
593            ),
594        }
595    })?;
596    let chunk_words = largest_fitting_chunk_words(
597        word_count,
598        output_count,
599        workers,
600        method,
601        allocated_staging_width,
602        controller_budget,
603    )
604    .ok_or_else(|| {
605        let minimum_bytes =
606            cpu_controller_plan(2, output_count, workers, method, allocated_staging_width)
607                .buffer_accounting()
608                .total_bytes;
609        Par2Error::ResourceLimitExceeded {
610            reason: format!(
611                "CPU repair controller needs at least {minimum_bytes} bytes, leaving {controller_budget} bytes after persistent state"
612            ),
613        }
614    })?;
615    let controller = cpu_controller_plan(
616        chunk_words.saturating_mul(2),
617        output_count,
618        workers,
619        method,
620        allocated_staging_width,
621    );
622    Ok((chunk_words, limit, controller))
623}
624
625pub(crate) fn build_write_targets(
626    plan: &RepairPlan,
627    par2_set: &Par2FileSet,
628) -> Result<Vec<RepairWriteTarget>> {
629    plan.missing_slices
630        .iter()
631        .map(|(file_id, local_slice)| {
632            let desc =
633                par2_set
634                    .file_description(file_id)
635                    .ok_or_else(|| Par2Error::ReedSolomonError {
636                        reason: format!("file description not found for {file_id}"),
637                    })?;
638            Ok(RepairWriteTarget {
639                file_id: *file_id,
640                filename: desc.filename.clone(),
641                offset: *local_slice as u64 * plan.slice_size,
642                file_end: desc.length,
643            })
644        })
645        .collect()
646}
647
648fn grouped_input_factors(coefficients: &matrix::Matrix) -> Vec<Vec<FactorIndex>> {
649    (0..coefficients.rows)
650        .map(|row_idx| {
651            coefficients
652                .row(row_idx)
653                .iter()
654                .enumerate()
655                .filter_map(|(input_idx, &factor)| {
656                    if factor == 0 {
657                        None
658                    } else {
659                        Some(FactorIndex {
660                            factor,
661                            input_idx: input_idx as u16,
662                        })
663                    }
664                })
665                .collect()
666        })
667        .collect()
668}
669
670// Cache-tile preferences are scheduling capabilities, not correctness limits.
671const PLAIN_IDEAL_CHUNK_BYTES: usize = 32 * 1024;
672const FOLDED_IDEAL_CHUNK_BYTES: usize = 8 * 1024;
673
674/// ParPar's aarch64 staging block: `sizeof(uint8x16x2_t)`, the `blockLen`
675/// argument every aarch64 `GF_PREPARE_PACKED_FUNCS` invocation passes
676/// (`parpar/gf16/gf16_clmul_neon.c:27`, `gf16_shuffle_neon.c`'s matching call).
677#[cfg(target_arch = "aarch64")]
678const NEON_PACKED_BLOCK_BYTES: usize = 32;
679
680/// ParPar's aarch64 checksum width: `sizeof(uint8x16_t)`, the final
681/// `GF_PREPARE_PACKED_FUNCS` argument, and the width
682/// `gf16_checksum_block_neon` (`parpar/gf16/gf16_checksum_arm.h:35-45`)
683/// accumulates into.
684#[cfg(target_arch = "aarch64")]
685const NEON_PACKED_CHECKSUM_BYTES: usize = 16;
686
687/// Selection gate for [`CpuKernelKind::NeonPacked`]. `WEAVER_PAR2_NEON_PACKED=0`
688/// pins the previous [`CpuKernelKind::Plain`] contract so the two staging
689/// shapes can be A/B'd in one binary; the default is the oracle's shape.
690#[cfg(target_arch = "aarch64")]
691fn neon_packed_enabled() -> bool {
692    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
693    *ENABLED.get_or_init(|| std::env::var_os("WEAVER_PAR2_NEON_PACKED").is_none_or(|v| v != "0"))
694}
695#[cfg(target_arch = "x86_64")]
696const XORJIT_AVX2_IDEAL_CHUNK_BYTES: usize = 128 * 1024;
697
698/// 64-byte-aligned backing for the folded staging streams so every 32-byte
699/// block sits cache-line aligned.
700#[repr(C, align(64))]
701#[derive(Clone, Copy)]
702struct StagingCell([u8; 64]);
703
704fn staging_cells_for(bytes: usize) -> Vec<StagingCell> {
705    vec![StagingCell([0u8; 64]); bytes.div_ceil(64)]
706}
707
708fn staging_bytes(cells: &[StagingCell]) -> &[u8] {
709    // Safe: StagingCell is a plain 64-byte array with alignment 64; viewing
710    // the contiguous cell storage as bytes narrows alignment only.
711    unsafe { std::slice::from_raw_parts(cells.as_ptr() as *const u8, cells.len() * 64) }
712}
713
714fn staging_bytes_mut(cells: &mut [StagingCell]) -> &mut [u8] {
715    unsafe { std::slice::from_raw_parts_mut(cells.as_mut_ptr() as *mut u8, cells.len() * 64) }
716}
717
718/// Persistent chunk-major controller output area.
719///
720/// Within each compute chunk, all output regions are contiguous, keeping the
721/// active input/output working set local to the worker before the transfer
722/// thread gathers one output for writing.
723struct AlignedOutputArea {
724    cells: Vec<StagingCell>,
725}
726
727impl AlignedOutputArea {
728    fn new(output_count: usize, byte_len: usize) -> Self {
729        Self {
730            cells: staging_cells_for(output_count.saturating_mul(byte_len)),
731        }
732    }
733
734    fn base(&mut self) -> usize {
735        self.cells.as_mut_ptr() as *mut u8 as usize
736    }
737}
738
739/// Lazily prepared multiply tables, one slot per distinct GF(2^16) factor.
740/// Entries are boxed so references handed to worker threads stay valid; the
741/// memo is fully populated before any parallel compute runs.
742struct MemoEntry {
743    prepared: crate::gf_simd::PreparedInputFactor,
744    /// Affine matrix forms for the GFNI folded split-layout kernel; only built
745    /// when that path is active.
746    affine: Option<crate::gf_simd::AffineMulMatrices>,
747    /// Shuffle2x table forms for the non-GFNI AVX2 folded kernel; only built
748    /// when that path (AVX2 without GFNI) is active.
749    shuffle2x: Option<crate::gf_simd::Shuffle2xTables>,
750}
751
752struct PreparedFactorMemo {
753    slots: Vec<Option<Box<MemoEntry>>>,
754}
755
756impl PreparedFactorMemo {
757    fn from_matrix(matrix: &matrix::Matrix, with_folded: bool) -> Self {
758        let mut slots: Vec<Option<Box<MemoEntry>>> = (0..1usize << 16).map(|_| None).collect();
759        // The folded split layout is shared; the GFNI affine kernel and the
760        // non-GFNI shuffle2x kernel consume it with different coefficient
761        // forms, so build whichever the active kernel needs.
762        let uses_gfni = with_folded && crate::gf_simd::folded_uses_gfni();
763        let uses_shuffle2x = with_folded && !uses_gfni;
764        // Factor 0 backs the padding lanes of partially filled groups.
765        let ensure = |factor: u16, slots: &mut Vec<Option<Box<MemoEntry>>>| {
766            let slot = &mut slots[factor as usize];
767            if slot.is_none() {
768                *slot = Some(Box::new(MemoEntry {
769                    prepared: crate::gf_simd::prepare_input_factor(factor),
770                    affine: uses_gfni.then(|| crate::gf_simd::precompute_affine_matrices(factor)),
771                    shuffle2x: uses_shuffle2x
772                        .then(|| crate::gf_simd::precompute_shuffle2x_tables(factor)),
773                }));
774            }
775        };
776        ensure(0, &mut slots);
777        for output_idx in 0..matrix.rows {
778            for source_idx in 0..matrix.cols {
779                ensure(matrix.get(output_idx, source_idx), &mut slots);
780            }
781        }
782        Self { slots }
783    }
784
785    #[inline]
786    fn get(&self, factor: u16) -> &crate::gf_simd::PreparedInputFactor {
787        &self.slots[factor as usize]
788            .as_deref()
789            .expect("factor prepared during memo construction")
790            .prepared
791    }
792
793    #[inline]
794    fn get_affine(&self, factor: u16) -> &crate::gf_simd::AffineMulMatrices {
795        self.slots[factor as usize]
796            .as_deref()
797            .expect("factor prepared during memo construction")
798            .affine
799            .as_ref()
800            .expect("affine matrices built for the folded path")
801    }
802
803    #[inline]
804    fn get_shuffle2x(&self, factor: u16) -> &crate::gf_simd::Shuffle2xTables {
805        self.slots[factor as usize]
806            .as_deref()
807            .expect("factor prepared during memo construction")
808            .shuffle2x
809            .as_ref()
810            .expect("shuffle2x tables built for the folded path")
811    }
812}
813
814/// XOR-JIT tier (x86_64, AVX2- or AVX512-without-GFNI): packed dispatch for
815/// every controller batch/output row. The normal bounded mode rotates two
816/// strict-W^X arenas. AVX2 may instead retain one immutable repair codebook
817/// when it fits without reducing the controller's data chunk.
818#[cfg(target_arch = "x86_64")]
819enum JitDispatchStorage {
820    RepairCodebook(Arc<reedsolomon_rs::xor_jit::packed::Avx2Codebook>),
821    ActiveArenas { arena_limit: usize },
822}
823
824#[cfg(target_arch = "x86_64")]
825struct JitMemo {
826    width: reedsolomon_rs::xor_jit::JitWidth,
827    input_grouping: usize,
828    output_count: usize,
829    storage: JitDispatchStorage,
830    reserved_bytes: usize,
831}
832
833#[cfg(target_arch = "x86_64")]
834impl JitMemo {
835    fn new(
836        width: reedsolomon_rs::xor_jit::JitWidth,
837        method: CpuMethodContract,
838        output_count: usize,
839        repair_factors: &[u16],
840        codebook_limit: usize,
841        available_bytes: usize,
842    ) -> std::result::Result<Self, reedsolomon_rs::xor_jit::packed::PackedBuildError> {
843        if !method.strict_wx_available {
844            return Err(
845                reedsolomon_rs::xor_jit::packed::PackedBuildError::InvalidInput(
846                    "XOR-JIT method contract lacks strict W^X capability",
847                ),
848            );
849        }
850        let input_grouping = method.input_grouping();
851        let codebook = matches!(width, reedsolomon_rs::xor_jit::JitWidth::Avx2)
852            .then(|| {
853                reedsolomon_rs::xor_jit::packed::Avx2Codebook::build(repair_factors, codebook_limit)
854            })
855            .transpose();
856        let (storage, reserved_bytes) = match codebook {
857            Ok(Some(codebook)) => {
858                let retained_bytes = codebook.retained_bytes();
859                (JitDispatchStorage::RepairCodebook(codebook), retained_bytes)
860            }
861            Ok(None) | Err(reedsolomon_rs::xor_jit::packed::PackedBuildError::Resource { .. }) => {
862                // Each controller staging area owns one immutable active
863                // arena and returns it to RW only after all workers finish.
864                let arena_limit =
865                    reedsolomon_rs::xor_jit::packed::PackedJitBatch::active_arena_upper_bound(
866                        width,
867                        output_count,
868                        input_grouping,
869                    )
870                    .ok_or(
871                        reedsolomon_rs::xor_jit::packed::PackedBuildError::Resource {
872                            requested_bytes: usize::MAX,
873                            limit_bytes: available_bytes,
874                        },
875                    )?;
876                let reserved_bytes = arena_limit.checked_mul(2).ok_or(
877                    reedsolomon_rs::xor_jit::packed::PackedBuildError::Resource {
878                        requested_bytes: usize::MAX,
879                        limit_bytes: available_bytes,
880                    },
881                )?;
882                if reserved_bytes > available_bytes {
883                    return Err(
884                        reedsolomon_rs::xor_jit::packed::PackedBuildError::Resource {
885                            requested_bytes: reserved_bytes,
886                            limit_bytes: available_bytes,
887                        },
888                    );
889                }
890                (
891                    JitDispatchStorage::ActiveArenas { arena_limit },
892                    reserved_bytes,
893                )
894            }
895            Err(error) => return Err(error),
896        };
897
898        Ok(Self {
899            width,
900            input_grouping,
901            output_count,
902            storage,
903            reserved_bytes,
904        })
905    }
906
907    #[inline]
908    fn get<'a>(
909        &self,
910        batch: &'a reedsolomon_rs::xor_jit::packed::PackedJitBatch,
911        output: usize,
912    ) -> &'a reedsolomon_rs::xor_jit::packed::PackedJitCode {
913        batch
914            .row(output)
915            .expect("packed JIT row exists for every controller output")
916    }
917
918    fn build_active_batch(
919        &self,
920        set: &StreamBatchSet,
921        workspace: &mut reedsolomon_rs::xor_jit::packed::PackedJitWorkspace,
922    ) -> std::result::Result<
923        reedsolomon_rs::xor_jit::packed::PackedJitBatch,
924        reedsolomon_rs::xor_jit::packed::PackedBuildError,
925    > {
926        if set.input_grouping != self.input_grouping
927            || set.coefficients.len() != self.output_count.saturating_mul(self.input_grouping)
928        {
929            return Err(
930                reedsolomon_rs::xor_jit::packed::PackedBuildError::InvalidInput(
931                    "controller coefficient batch shape does not match the JIT memo",
932                ),
933            );
934        }
935        let rows = set
936            .coefficients
937            .chunks_exact(self.input_grouping)
938            .collect::<Vec<_>>();
939        match &self.storage {
940            JitDispatchStorage::RepairCodebook(codebook) => codebook.build_batch(&rows),
941            JitDispatchStorage::ActiveArenas { arena_limit } => {
942                workspace.build(self.width, &rows, *arena_limit)
943            }
944        }
945    }
946
947    #[inline]
948    fn reserved_bytes(&self) -> usize {
949        self.reserved_bytes
950    }
951
952    /// Which dispatch storage the reservation bought. The two differ in cost
953    /// by orders of magnitude: the codebook compiles every factor body once
954    /// for the whole repair, while the active arenas regenerate code for every
955    /// (output, source batch) pair on every pass over the slice.
956    fn storage_kind(&self) -> &'static str {
957        match self.storage {
958            JitDispatchStorage::RepairCodebook(_) => "codebook",
959            JitDispatchStorage::ActiveArenas { .. } => "active-arenas",
960        }
961    }
962}
963
964/// The one repair shape the XOR-JIT selection rule reads: how much memory the
965/// repair may use, and how large the controller it has to fill is.
966#[cfg(target_arch = "x86_64")]
967#[derive(Clone, Copy, Debug, Eq, PartialEq)]
968struct XorJitSelection {
969    width: reedsolomon_rs::xor_jit::JitWidth,
970    jit_method: CpuMethodContract,
971    /// The non-JIT method this host would otherwise run.
972    baseline_method: CpuMethodContract,
973    output_count: usize,
974    word_count: usize,
975    workers: usize,
976    budget: usize,
977}
978
979/// What each kernel's data chunk would be under the repair's memory limit.
980///
981/// The XOR-JIT reservation — a codebook, or the two active arenas, whose size
982/// grows with the output count — is charged against the same limit as the
983/// controller's data buffers, so it is paid for in passes over the sources.
984#[cfg(target_arch = "x86_64")]
985#[derive(Clone, Copy, Debug, Eq, PartialEq)]
986struct XorJitBudgetDecision {
987    baseline_chunk_words: Option<usize>,
988    jit_chunk_words: Option<usize>,
989}
990
991#[cfg(target_arch = "x86_64")]
992impl XorJitBudgetDecision {
993    /// The JIT tier is worth its reservation only when it costs no chunk: a
994    /// faster kernel over a smaller chunk still reads the sources more times.
995    fn accepted(self) -> bool {
996        match (self.jit_chunk_words, self.baseline_chunk_words) {
997            (Some(jit), Some(baseline)) => jit >= baseline,
998            (Some(_), None) => true,
999            (None, _) => false,
1000        }
1001    }
1002}
1003
1004#[cfg(target_arch = "x86_64")]
1005impl XorJitSelection {
1006    /// Compares the chunk an XOR-JIT controller would get after reserving
1007    /// `jit_reserved_bytes` against the chunk the non-JIT method would get
1008    /// from the whole limit.
1009    ///
1010    /// Pure size arithmetic: it allocates no data buffers and maps no W^X
1011    /// pages, so the selection rule is testable on its own.
1012    fn budget_decision(self, jit_reserved_bytes: usize) -> XorJitBudgetDecision {
1013        XorJitBudgetDecision {
1014            baseline_chunk_words: largest_fitting_chunk_words(
1015                self.word_count,
1016                self.output_count,
1017                self.workers,
1018                self.baseline_method,
1019                self.baseline_method.staging_width(),
1020                self.budget,
1021            ),
1022            jit_chunk_words: largest_fitting_chunk_words(
1023                self.word_count,
1024                self.output_count,
1025                self.workers,
1026                self.jit_method,
1027                self.jit_method.staging_width(),
1028                self.budget.saturating_sub(jit_reserved_bytes),
1029            ),
1030        }
1031    }
1032
1033    /// Builds the XOR-JIT memo when the tier earns its reservation under this
1034    /// repair's memory limit, and returns `None` when it does not.
1035    ///
1036    /// Two outcomes are selection, not failure, and take the non-JIT kernel: a
1037    /// reservation that does not fit the limit at all
1038    /// (`PackedBuildError::Resource`, or a controller base already over the
1039    /// limit), and one that fits but would shrink the data chunk below what
1040    /// the non-JIT method gets. Every other construction failure —
1041    /// `InvalidInput`, `Io`, and the per-batch sealing at run time — stays a
1042    /// hard error: a W^X fault is a controller defect, never a silent
1043    /// downgrade to a different arithmetic method.
1044    fn select_memo(self, repair_factors: &[u16]) -> Result<Option<JitMemo>> {
1045        let Self {
1046            width,
1047            jit_method,
1048            output_count,
1049            word_count,
1050            workers,
1051            budget,
1052            ..
1053        } = self;
1054        let jit_staging_width = jit_method.staging_width();
1055        let minimum_controller_bytes =
1056            cpu_controller_plan(2, output_count, workers, jit_method, jit_staging_width)
1057                .buffer_accounting()
1058                .total_bytes;
1059        let Some(available_jit_bytes) = budget.checked_sub(minimum_controller_bytes) else {
1060            info!(
1061                ?width,
1062                minimum_controller_bytes,
1063                budget,
1064                "XOR-JIT controller base does not fit the repair memory limit; selecting the non-JIT kernel"
1065            );
1066            return Ok(None);
1067        };
1068        let full_controller_bytes = cpu_controller_plan(
1069            word_count.saturating_mul(2),
1070            output_count,
1071            workers,
1072            jit_method,
1073            jit_staging_width,
1074        )
1075        .buffer_accounting()
1076        .total_bytes;
1077        let codebook_limit = budget.saturating_sub(full_controller_bytes);
1078        let memo = match JitMemo::new(
1079            width,
1080            jit_method,
1081            output_count,
1082            repair_factors,
1083            codebook_limit,
1084            available_jit_bytes,
1085        ) {
1086            Ok(memo) => memo,
1087            Err(reedsolomon_rs::xor_jit::packed::PackedBuildError::Resource {
1088                requested_bytes,
1089                limit_bytes,
1090            }) => {
1091                info!(
1092                    ?width,
1093                    requested_bytes,
1094                    limit_bytes,
1095                    "XOR-JIT reservation does not fit the repair memory limit; selecting the non-JIT kernel"
1096                );
1097                return Ok(None);
1098            }
1099            Err(error) => {
1100                return Err(Par2Error::ReedSolomonError {
1101                    reason: format!("XOR-JIT controller capacity setup failed: {error}"),
1102                });
1103            }
1104        };
1105        let reserved_bytes = memo.reserved_bytes();
1106        let storage = memo.storage_kind();
1107        let decision = self.budget_decision(reserved_bytes);
1108        if !decision.accepted() {
1109            info!(
1110                ?width,
1111                storage,
1112                reserved_bytes,
1113                jit_chunk_words = ?decision.jit_chunk_words,
1114                baseline_chunk_words = ?decision.baseline_chunk_words,
1115                "XOR-JIT reservation would shrink the repair chunk; selecting the non-JIT kernel"
1116            );
1117            return Ok(None);
1118        }
1119        info!(
1120            ?width,
1121            storage,
1122            reserved_bytes,
1123            jit_chunk_words = ?decision.jit_chunk_words,
1124            baseline_chunk_words = ?decision.baseline_chunk_words,
1125            "XOR-JIT reservation costs no repair chunk; selecting the XOR-JIT kernel"
1126        );
1127        Ok(Some(memo))
1128    }
1129}
1130
1131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1132enum CpuKernelKind {
1133    Plain,
1134    Folded,
1135    /// ParPar's aarch64 packed-prepare contract: 32-byte staging blocks with
1136    /// an *identity* block transform and a 16-byte checksum accumulator.
1137    ///
1138    /// This is not a different arithmetic kernel — the multiply is the same
1139    /// CLMUL/NEON ladder [`CpuKernelKind::Plain`] already dispatches to
1140    /// (`gf_simd::mul_acc_input_batch`'s aarch64 arm). It is the *staging*
1141    /// contract, and it exists because ParPar's aarch64 methods stage exactly
1142    /// this way:
1143    ///
1144    /// ```text
1145    /// GF_PREPARE_PACKED_FUNCS(gf16_clmul, _neon,
1146    ///     sizeof(uint8x16x2_t),        // blockLen == 32
1147    ///     gf16_prepare_block_neon,     // the block transform: a plain copy
1148    ///     ...,
1149    ///     gf16_checksum_block_neon,
1150    ///     ...,
1151    ///     sizeof(uint8x16_t))          // checksum width == 16
1152    /// ```
1153    ///
1154    /// `parpar/gf16/gf16_clmul_neon.c:27`, with `gf16_prepare_block_neon`
1155    /// defined at `parpar/gf16/gf16_neon_common.h:110-117` under the comment
1156    /// "copying prepare block for both shuffle/clmul" — i.e. on aarch64 the
1157    /// oracle does **no** altmap/deinterleave in memory. Its NEON kernels take
1158    /// linear bytes and deinterleave in-register with `LD2`/`ST2`, which is
1159    /// what rarpar's `clmul_load_planes` already does.
1160    ///
1161    /// So the faithful aarch64 staging is [`Self::Plain`]'s (identity) data
1162    /// path carrying [`Self::Folded`]'s block granularity. Adopting `Folded`
1163    /// itself would be *anti*-faithful twice over: it would impose a byte
1164    /// deinterleave the oracle does not perform, and its compute branch routes
1165    /// through the VTBL shuffle kernels, whereas ParPar's aarch64
1166    /// `default_method()` prefers CLMUL over `GF16_SHUFFLE_NEON` for every
1167    /// input count this controller batches (`parpar/gf16/gf16mul.cpp:1606-1624`;
1168    /// under `PARPAR_SLIM_GF16`, which par2cmdline-turbo's own build defines at
1169    /// `Makefile.am:330`, CLMUL is unconditional).
1170    #[cfg(target_arch = "aarch64")]
1171    NeonPacked,
1172    #[cfg(target_arch = "x86_64")]
1173    XorJit(reedsolomon_rs::xor_jit::JitWidth),
1174}
1175
1176impl CpuKernelKind {
1177    /// Complete method contract. Controller policy reads this value; the enum
1178    /// itself is only the arithmetic dispatch selector.
1179    fn method(self) -> CpuMethodContract {
1180        match self {
1181            Self::Plain => CpuMethodContract {
1182                stride: 2,
1183                alignment: 64,
1184                ideal_input_multiple: 1,
1185                staging_multiple: 1,
1186                ideal_chunk_size: PLAIN_IDEAL_CHUNK_BYTES,
1187                checksum_width: 2,
1188                prefetch: CpuPrefetch {
1189                    inputs_per_invoke: 0,
1190                    input_distance_shift: 0,
1191                    output: false,
1192                },
1193                strict_wx_available: false,
1194            },
1195            Self::Folded => CpuMethodContract {
1196                stride: crate::gf_simd::SPLIT_BLOCK_BYTES,
1197                alignment: 64,
1198                ideal_input_multiple: crate::gf_simd::FOLDED_GROUP,
1199                staging_multiple: crate::gf_simd::FOLDED_GROUP,
1200                ideal_chunk_size: FOLDED_IDEAL_CHUNK_BYTES,
1201                checksum_width: crate::gf_simd::SPLIT_BLOCK_BYTES,
1202                prefetch: CpuPrefetch {
1203                    inputs_per_invoke: 0,
1204                    input_distance_shift: 0,
1205                    output: false,
1206                },
1207                strict_wx_available: false,
1208            },
1209            // Identical to `Plain` except for the two fields ParPar's aarch64
1210            // packed staging actually fixes: the block granularity and the
1211            // checksum width. `ideal_input_multiple`/`staging_multiple` stay 1
1212            // because the oracle's region interleave (`CLMUL_NUM_REGIONS == 8`,
1213            // `parpar/gf16/gf16_clmul_neon_base.h:49-51`) lives *inside* the
1214            // muladd kernel — rarpar already carries it as
1215            // `gf_simd::CLMUL_SRC_GROUP` — not in the controller's batch shape.
1216            #[cfg(target_arch = "aarch64")]
1217            Self::NeonPacked => CpuMethodContract {
1218                stride: NEON_PACKED_BLOCK_BYTES,
1219                alignment: 64,
1220                ideal_input_multiple: 1,
1221                staging_multiple: 1,
1222                ideal_chunk_size: PLAIN_IDEAL_CHUNK_BYTES,
1223                checksum_width: NEON_PACKED_CHECKSUM_BYTES,
1224                prefetch: CpuPrefetch {
1225                    inputs_per_invoke: 0,
1226                    input_distance_shift: 0,
1227                    output: false,
1228                },
1229                strict_wx_available: false,
1230            },
1231            #[cfg(target_arch = "x86_64")]
1232            Self::XorJit(width) => CpuMethodContract {
1233                stride: width.block_bytes(),
1234                alignment: 32,
1235                ideal_input_multiple: 1,
1236                staging_multiple: 1,
1237                ideal_chunk_size: XORJIT_AVX2_IDEAL_CHUNK_BYTES,
1238                checksum_width: width.block_bytes() / 16,
1239                prefetch: CpuPrefetch {
1240                    inputs_per_invoke: 1,
1241                    input_distance_shift: 1,
1242                    output: true,
1243                },
1244                strict_wx_available: reedsolomon_rs::xor_jit::strict_wx_available(),
1245            },
1246        }
1247    }
1248}
1249
1250/// One double-buffered set of streamed source chunks. Which sources feed
1251/// which outputs is read directly from the decode matrix inside the compute
1252/// tasks — no gather lists are materialized.
1253struct StreamBatchSet {
1254    /// Per-source chunk buffers (generic kernel path).
1255    bufs: Vec<Vec<u8>>,
1256    /// XOR-JIT packed inputs in one persistent, aligned, chunk-major area.
1257    /// Each compute chunk contains contiguous source regions.
1258    #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
1259    packed: Vec<StagingCell>,
1260    #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
1261    packed_stride: usize,
1262    /// Folded path: per group of FOLDED_GROUP sources, their split-layout
1263    /// blocks interleaved at 32-byte granularity into one 64-byte-aligned
1264    /// stream.
1265    staging: Vec<Vec<StagingCell>>,
1266    /// Coefficients for this staged group, output-major with a fixed
1267    /// `input_grouping` stride. Unused lanes in a short final group are zero.
1268    coefficients: Vec<u16>,
1269    input_grouping: usize,
1270    start: usize,
1271    len: usize,
1272}
1273
1274impl StreamBatchSet {
1275    fn new(
1276        max_byte_len: usize,
1277        input_grouping: usize,
1278        allocated_staging_width: usize,
1279        output_count: usize,
1280        folded: bool,
1281        xorjit: bool,
1282        gpu_staging: bool,
1283    ) -> Self {
1284        let groups = allocated_staging_width / crate::gf_simd::FOLDED_GROUP;
1285        let bufs = if gpu_staging || (!xorjit && !folded) {
1286            vec![vec![0u8; max_byte_len]; allocated_staging_width]
1287        } else {
1288            Vec::new()
1289        };
1290        let packed = if xorjit {
1291            staging_cells_for(allocated_staging_width.saturating_mul(max_byte_len))
1292        } else {
1293            Vec::new()
1294        };
1295        let staging = if folded {
1296            (0..groups)
1297                .map(|_| staging_cells_for(max_byte_len * crate::gf_simd::FOLDED_GROUP))
1298                .collect()
1299        } else {
1300            Vec::new()
1301        };
1302        Self {
1303            bufs,
1304            packed,
1305            packed_stride: max_byte_len,
1306            staging,
1307            coefficients: vec![0; output_count.saturating_mul(input_grouping)],
1308            input_grouping,
1309            start: 0,
1310            len: 0,
1311        }
1312    }
1313
1314    #[inline]
1315    fn coefficient(&self, output: usize, lane: usize) -> u16 {
1316        self.coefficients[output * self.input_grouping + lane]
1317    }
1318}
1319
1320/// Read one streamed source chunk (a present input slice range or a recovery
1321/// block range) into `dst`, zero-padding any short tail.
1322pub(crate) struct StreamSourceReader {
1323    file_id: FileId,
1324    reader: Box<dyn FileRangeReader>,
1325}
1326
1327#[allow(clippy::too_many_arguments)]
1328pub(crate) fn read_stream_source_chunk(
1329    plan: &RepairPlan,
1330    par2_set: &Par2FileSet,
1331    file_access: &mut dyn FileAccess,
1332    recovery_files: &mut HashMap<PathBuf, File>,
1333    source_reader: &mut Option<StreamSourceReader>,
1334    available_inputs: usize,
1335    source_idx: usize,
1336    byte_start: usize,
1337    dst: &mut [u8],
1338) -> Result<()> {
1339    if source_idx < available_inputs {
1340        let global_idx = plan.available_input_global_indices[source_idx];
1341        let (file_id, local_slice) = plan.global_to_file[global_idx];
1342        let offset = local_slice as u64 * plan.slice_size + byte_start as u64;
1343        let file_length = par2_set
1344            .file_description(&file_id)
1345            .ok_or_else(|| Par2Error::ReedSolomonError {
1346                reason: format!("file description not found for {file_id}"),
1347            })?
1348            .length;
1349        let expected_len = file_length.saturating_sub(offset).min(dst.len() as u64) as usize;
1350        if source_reader
1351            .as_ref()
1352            .is_none_or(|open| open.file_id != file_id)
1353        {
1354            *source_reader = file_access
1355                .open_range_reader(&file_id)
1356                .map_err(Par2Error::Io)?
1357                .map(|reader| StreamSourceReader { file_id, reader });
1358        }
1359        if let Some(open) = source_reader.as_mut() {
1360            open.reader
1361                .seek(SeekFrom::Start(offset))
1362                .and_then(|_| open.reader.read_exact(&mut dst[..expected_len]))
1363                .map_err(Par2Error::Io)?;
1364        } else {
1365            let mut read_len = 0usize;
1366            while read_len < expected_len {
1367                let read = file_access
1368                    .read_file_range_into(
1369                        &file_id,
1370                        offset + read_len as u64,
1371                        &mut dst[read_len..expected_len],
1372                    )
1373                    .map_err(Par2Error::Io)?;
1374                if read == 0 {
1375                    return Err(Par2Error::Io(std::io::Error::new(
1376                        std::io::ErrorKind::UnexpectedEof,
1377                        format!("source slice {file_id}:{local_slice} ended during repair"),
1378                    )));
1379                }
1380                read_len += read;
1381            }
1382        }
1383        dst[expected_len..].fill(0);
1384    } else {
1385        *source_reader = None;
1386        let exp = plan.recovery_exponents[source_idx - available_inputs];
1387        let rs = par2_set
1388            .recovery_slices
1389            .get(&exp)
1390            .ok_or_else(|| Par2Error::ReedSolomonError {
1391                reason: format!("recovery block with exponent {exp} not found"),
1392            })?;
1393        fill_recovery_chunk(&rs.data, byte_start, dst, recovery_files).map_err(Par2Error::Io)?;
1394    }
1395    Ok(())
1396}
1397
1398fn prepare_stream_source(
1399    set: &mut StreamBatchSet,
1400    lane: usize,
1401    source: &[u8],
1402    aligned_len: usize,
1403    #[cfg(target_arch = "x86_64")] chunk_len: usize,
1404    #[cfg(not(target_arch = "x86_64"))] _chunk_len: usize,
1405    kernel: CpuKernelKind,
1406) {
1407    match kernel {
1408        #[cfg(target_arch = "x86_64")]
1409        CpuKernelKind::XorJit(width) => {
1410            let block = width.block_bytes();
1411            debug_assert_eq!(aligned_len % block, 0);
1412            debug_assert_eq!(chunk_len % block, 0);
1413            let num_chunks = aligned_len.div_ceil(chunk_len);
1414            let packed = staging_bytes_mut(&mut set.packed);
1415            for chunk in 0..num_chunks {
1416                let source_start = chunk * chunk_len;
1417                let source_len = (aligned_len - source_start).min(chunk_len);
1418                let lane_start = chunk
1419                    .saturating_mul(set.input_grouping)
1420                    .saturating_mul(chunk_len)
1421                    .saturating_add(lane.saturating_mul(source_len));
1422                packed[lane_start..lane_start + source_len].fill(0);
1423                for offset in (0..source_len).step_by(block) {
1424                    // SAFETY: XOR-JIT selection proves the required CPU
1425                    // features; source and destination are disjoint, aligned
1426                    // controller regions exactly one JIT block wide.
1427                    unsafe {
1428                        width.prepare_block(
1429                            &source[source_start + offset..source_start + offset + block],
1430                            &mut packed[lane_start + offset..lane_start + offset + block],
1431                        );
1432                    }
1433                }
1434            }
1435        }
1436        CpuKernelKind::Folded => {
1437            let group = lane / crate::gf_simd::FOLDED_GROUP;
1438            let group_lane = lane % crate::gf_simd::FOLDED_GROUP;
1439            crate::gf_simd::split_encode_scatter(
1440                &source[..aligned_len],
1441                staging_bytes_mut(&mut set.staging[group]),
1442                group_lane,
1443            );
1444        }
1445        // `gf16_prepare_block_neon` is a plain 32-byte copy
1446        // (`parpar/gf16/gf16_neon_common.h:110-117`), and the copy into
1447        // `set.bufs` below already is that copy. Nothing further to stage.
1448        #[cfg(target_arch = "aarch64")]
1449        CpuKernelKind::NeonPacked => {}
1450        CpuKernelKind::Plain => {}
1451    }
1452    if !set.bufs.is_empty() {
1453        set.bufs[lane][..aligned_len].copy_from_slice(&source[..aligned_len]);
1454    }
1455}
1456
1457#[inline]
1458fn gf16_mul2(value: u16) -> u16 {
1459    (value << 1) ^ if value & 0x8000 != 0 { 0x100b } else { 0 }
1460}
1461
1462/// Four packed little-endian GF(2^16) lanes multiplied by 2 at once.
1463///
1464/// Per lane this is exactly [`gf16_mul2`]: shift left, and reduce by 0x100b
1465/// when the lane's top bit was set. Masking with `LANE_LO` before the shift
1466/// stops a lane's high bit from bleeding into its neighbour, and the reduction
1467/// term is built by a single multiply — safe across lanes because each lane's
1468/// carry bit is 0 or 1 and `0x100b` fits in 16 bits, so no product crosses a
1469/// lane boundary.
1470#[inline]
1471fn gf16_mul2_x4(v: u64) -> u64 {
1472    const LANE_HI: u64 = 0x8000_8000_8000_8000;
1473    const LANE_LO: u64 = 0x7fff_7fff_7fff_7fff;
1474    let carry = (v & LANE_HI) >> 15;
1475    ((v & LANE_LO) << 1) ^ carry.wrapping_mul(0x100b)
1476}
1477
1478/// ParPar's NEON packed-checksum kernel, ported for aarch64.
1479///
1480/// # Which oracle routine this is
1481///
1482/// Every aarch64 method in par2cmdline-turbo's dispatch ladder points
1483/// `prepare_packed_cksum` at a `_neon` entry point:
1484///
1485/// * `parpar/gf16/gf16mul.cpp:1607-1624` — the aarch64 leg of
1486///   `GF16Mul::default_method()` returns only `GF16_CLMUL_SHA3`,
1487///   `GF16_SHUFFLE_NEON` or `GF16_CLMUL_NEON` once SVE/SVE2 are ruled out
1488///   (Apple silicon has neither).
1489/// * `parpar/gf16/gf16mul.cpp:771` — `GF16_SHUFFLE_NEON` sets
1490///   `prepare_packed_cksum = &gf16_shuffle_prepare_packed_cksum_neon`.
1491/// * `parpar/gf16/gf16mul.cpp:804` — `GF16_CLMUL_NEON` sets
1492///   `prepare_packed_cksum = &gf16_clmul_prepare_packed_cksum_neon`.
1493/// * `parpar/gf16/gf16mul.cpp:834` — `GF16_CLMUL_SHA3` reuses that same
1494///   `gf16_clmul_prepare_packed_cksum_neon`.
1495///
1496/// Those two symbols come from `GF_PREPARE_PACKED_FUNCS`, whose two
1497/// definitions (`parpar/gf16/gf16_global.h:376` and `:420`, the arms of
1498/// `#ifdef PARPAR_INCLUDE_BASIC_OPS` at `:374`) both expand the checksum
1499/// entry point through `GF_PREPARE_PACKED_CKSUM_FUNCS` (`:347`, body
1500/// `:348-350` feeding `gf16_prepare_packed`, `:85-219`). There are exactly two
1501/// NEON instantiations — `parpar/gf16/gf16_shuffle_neon.c:322` and
1502/// `parpar/gf16/gf16_clmul_neon.c:25` — and both pass the same checksum
1503/// quadruple: `gf16_checksum_block_neon, gf16_checksum_blocku_neon,
1504/// gf16_checksum_exp_neon, gf16_checksum_prepare_neon` with
1505/// `align = sizeof(uint8x16_t)`. So the block-level NEON mechanism is
1506/// `parpar/gf16/gf16_checksum_arm.h`, and nothing else on aarch64.
1507///
1508/// # The mechanism
1509///
1510/// * Accumulator arrangement: a single `uint8x16_t` held in a register across
1511///   the whole block loop (`gf16_checksum_arm.h:38-44`); `gf16_checksum_load` /
1512///   `gf16_checksum_store` are plain typed loads/stores on little-endian
1513///   (`:27-32`) precisely so the value stays in a register.
1514/// * mul2 idiom (`gf16_vec_mul2_neon`, `:6-15`): reinterpret as `int16x8_t`,
1515///   `vaddq_s16(v, v)` for the per-lane shift-left, `vshrq_n_s16(v, 15)` to
1516///   broadcast each lane's top bit into an all-ones mask, `vandq_s16` that mask
1517///   with a splat of `GF16_POLYNOMIAL & 0xffff` (`gf16_global.h:9` →
1518///   `0x100b`), and `veorq_s16` the two together.
1519/// * XOR reduction (`:41-42`): the block is walked in ascending
1520///   `sizeof(uint8x16_t)` steps, each `vld1q_u8` folded into the accumulator
1521///   with `veorq_u8`. One `mul2` per block, then pure XOR.
1522///
1523/// The generated code is the oracle's, checked on the `PLANES == 2` arm
1524/// (`--emit asm`, rustc 1.97.1, release): `dup.8h v2, #4107` hoisted out of
1525/// both loops, then per block `add.8h v3, v0, v0` / `cmlt.8h v0, v0, #0` /
1526/// `and.16b v0, v0, v2` / `eor.16b v0, v0, v3`, and per region
1527/// `ldp q3, q4, [x11]` + two `eor.16b`. Two LLVM canonicalizations, neither
1528/// semantic: `sshr #15` becomes the equivalent `cmlt #0` sign mask, and the
1529/// two `vld1q_u8` become one `ldp`. Accumulators stay in `v0`/`v1` across the
1530/// whole sweep, never spilling — the register residency the oracle relies on.
1531///
1532/// # Adaptation to rarpar's contract (deviations, called out)
1533///
1534/// * **Accumulator width.** ParPar's checksum is *always* one `uint8x16_t`;
1535///   rarpar's `CpuMethodContract::checksum_width` varies (2 for `Plain`, 32 for
1536///   `Folded`, 2/4 for x86 XOR-JIT). This port keeps one `uint8x16_t` per 16
1537///   bytes of contract width — `PLANES = checksum_width / 16` independent
1538///   accumulators, each running the oracle's exact instruction sequence over
1539///   its own 16-byte plane of every region. At `PLANES == 1` it is the oracle
1540///   instruction-for-instruction; at `PLANES > 1` it is the oracle replicated
1541///   per plane, which is the plane-permutation class of adaptation (zero
1542///   instruction changes, just more registers). `Folded`'s width 32 is
1543///   `PLANES == 2` — but see "Reachability" below for which widths aarch64
1544///   actually produces today.
1545/// * **Widths below 16 bytes have no oracle NEON form at all.** ParPar never
1546///   produces a sub-vector checksum, so `Plain`'s width 2 (and the x86-only
1547///   XOR-JIT widths) cannot be served faithfully and are left on the portable
1548///   arm. `fold` returns `None` for them rather than inventing a mechanism.
1549/// * **Driver, not kernel.** ParPar fuses the checksum into
1550///   `gf16_prepare_packed`'s block loop (`gf16_global.h:126`, `:153`) with the
1551///   accumulator as an always-inlined local; rarpar stages separately and calls
1552///   [`fold_packed_checksum`] over a whole buffer. Holding the accumulator in
1553///   registers for the entire sweep here is what the oracle's
1554///   `HEDLEY_ALWAYS_INLINE` + local `uint8x16_t checksum = vdupq_n_u8(0)`
1555///   (`gf16_shuffle_neon.c:322`, `cksumInit`) compiles to. No arithmetic
1556///   changes.
1557/// * **Not ported:** `gf16_checksum_blocku_neon` (`:47-62`),
1558///   `gf16_checksum_exp_neon` (`:64-77`) and `gf16_checksum_prepare_neon`
1559///   (`:79-94`). rarpar's staging is block-aligned and zero-padded before the
1560///   checksum runs, so the unaligned-tail and exponentiate-over-zero-fill paths
1561///   have no call site; there is nothing for them to be faithful *to*.
1562///
1563/// # Reachability on aarch64 — RESOLVED, read this before optimizing here
1564///
1565/// This arm used to be **inert in production on aarch64**. It is now live, and
1566/// the history is worth keeping because it records what the gap actually was.
1567///
1568/// Previously: [`CpuKernelKind::Folded`] was the only contract with a
1569/// 16-byte-multiple checksum width, and it is gated on
1570/// `gf_simd::altmap_supported()`, which is `#[cfg(target_arch = "x86_64")]`-only
1571/// (`gf_simd.rs:498-505`) because the split/altmap staging kernels exist only
1572/// for SSSE3/AVX2 (`gf_simd.rs:589-620`). aarch64 therefore selected
1573/// [`CpuKernelKind::Plain`] (`stride == 2`, `checksum_width == 2`), and tracing
1574/// every `fold_packed_checksum` call through the four par2 repair corpus cases
1575/// on an M5 confirmed 100% of swept bytes arrived as `(block_len 2, width 2)`.
1576///
1577/// The fix was *not* to build an aarch64 altmap tier. ParPar does not altmap on
1578/// aarch64 at all: `gf16_prepare_block_neon` is a plain 32-byte copy
1579/// (`parpar/gf16/gf16_neon_common.h:110-117`, comment "copying prepare block
1580/// for both shuffle/clmul"), and its NEON kernels deinterleave in-register with
1581/// `LD2`/`ST2`. What the oracle *does* stage on aarch64 is a 32-byte block with
1582/// a 16-byte checksum (`GF_PREPARE_PACKED_FUNCS(gf16_clmul, _neon,
1583/// sizeof(uint8x16x2_t), gf16_prepare_block_neon, …, sizeof(uint8x16_t))`,
1584/// `parpar/gf16/gf16_clmul_neon.c:27`). [`CpuKernelKind::NeonPacked`] is
1585/// exactly that contract, so this arm now runs at `PLANES == 1` — which is
1586/// the checksum recurrence reduced to `gf16_checksum_block_neon` verbatim.
1587///
1588/// (measured 5.05-6.61x over the portable arm at widths 16/32/64)
1589#[cfg(target_arch = "aarch64")]
1590mod parpar_neon_checksum {
1591    use std::arch::aarch64::*;
1592
1593    /// `GF16_POLYNOMIAL & 0xffff` — `parpar/gf16/gf16_global.h:9` gives
1594    /// `0x1100b`, and `gf16_checksum_arm.h:11` masks it to 16 bits.
1595    const POLY: i16 = 0x100b;
1596
1597    /// Verbatim `gf16_vec_mul2_neon`, `parpar/gf16/gf16_checksum_arm.h:6-15`.
1598    ///
1599    /// # Safety
1600    /// NEON is baseline on aarch64; no further preconditions.
1601    #[inline(always)]
1602    unsafe fn gf16_vec_mul2_neon(v: uint8x16_t) -> uint8x16_t {
1603        unsafe {
1604            // `_v` in the oracle; renamed only because a leading underscore
1605            // reads as "unused" in Rust.
1606            let vs = vreinterpretq_s16_u8(v);
1607            vreinterpretq_u8_s16(veorq_s16(
1608                vaddq_s16(vs, vs),
1609                vandq_s16(vdupq_n_s16(POLY), vshrq_n_s16::<15>(vs)),
1610            ))
1611        }
1612    }
1613
1614    /// `gf16_checksum_block_neon`, `parpar/gf16/gf16_checksum_arm.h:35-45`,
1615    /// with the accumulator widened to `PLANES` vectors (see the module note).
1616    ///
1617    /// # Safety
1618    /// `src` must be readable for `block_len` bytes, and `block_len` must be a
1619    /// non-zero multiple of `PLANES * 16`.
1620    #[inline(always)]
1621    unsafe fn checksum_block<const PLANES: usize>(
1622        acc: &mut [uint8x16_t; PLANES],
1623        src: *const u8,
1624        block_len: usize,
1625    ) {
1626        unsafe {
1627            for lane in acc.iter_mut() {
1628                *lane = gf16_vec_mul2_neon(*lane);
1629            }
1630            let mut i = 0usize;
1631            while i < block_len {
1632                for (plane, lane) in acc.iter_mut().enumerate() {
1633                    *lane = veorq_u8(*lane, vld1q_u8(src.add(i + plane * 16)));
1634                }
1635                i += PLANES * 16;
1636            }
1637        }
1638    }
1639
1640    /// The oracle's block loop with the accumulator register-resident across
1641    /// every block, which is what `gf16_prepare_packed` inlines to.
1642    ///
1643    /// # Safety
1644    /// `out` must be writable for `PLANES * 16` bytes, and `block_len` must be
1645    /// a non-zero multiple of `PLANES * 16`.
1646    #[inline(always)]
1647    unsafe fn fold_planes<const PLANES: usize>(data: &[u8], block_len: usize, out: &mut [u8]) {
1648        unsafe {
1649            let mut acc = [vdupq_n_u8(0); PLANES];
1650            let mut src = data.as_ptr();
1651            for _ in 0..(data.len() / block_len) {
1652                checksum_block::<PLANES>(&mut acc, src, block_len);
1653                src = src.add(block_len);
1654            }
1655            for (plane, lane) in acc.iter().enumerate() {
1656                vst1q_u8(out.as_mut_ptr().add(plane * 16), *lane);
1657            }
1658        }
1659    }
1660
1661    /// Whole-buffer sweep. `None` when the shape has no oracle NEON form —
1662    /// the caller then uses the portable arm.
1663    pub(super) fn fold(data: &[u8], block_len: usize, checksum_width: usize) -> Option<[u8; 64]> {
1664        if checksum_width == 0
1665            || !checksum_width.is_multiple_of(16)
1666            || checksum_width > 64
1667            || block_len == 0
1668            || !block_len.is_multiple_of(checksum_width)
1669        {
1670            return None;
1671        }
1672        let mut out = [0u8; 64];
1673        // SAFETY: `out` is 64 bytes and `checksum_width <= 64`, so each arm
1674        // writes within bounds; `block_len` is a non-zero multiple of
1675        // `checksum_width == PLANES * 16`; the loop reads
1676        // `data.len() / block_len` whole blocks.
1677        unsafe {
1678            match checksum_width / 16 {
1679                1 => fold_planes::<1>(data, block_len, &mut out),
1680                2 => fold_planes::<2>(data, block_len, &mut out),
1681                3 => fold_planes::<3>(data, block_len, &mut out),
1682                4 => fold_planes::<4>(data, block_len, &mut out),
1683                _ => return None,
1684            }
1685        }
1686        Some(out)
1687    }
1688
1689    /// Single-block form matching `gf16_checksum_block_neon`'s own signature
1690    /// (load accumulator, fold one block, store it back). Used by the fidelity
1691    /// tests, which drive the oracle contract block by block.
1692    ///
1693    /// `false` when the shape has no oracle NEON form.
1694    #[cfg_attr(not(test), allow(dead_code))]
1695    pub(super) fn update_block(checksum: &mut [u8], block: &[u8]) -> bool {
1696        let width = checksum.len();
1697        if width == 0
1698            || !width.is_multiple_of(16)
1699            || width > 64
1700            || block.is_empty()
1701            || !block.len().is_multiple_of(width)
1702        {
1703            return false;
1704        }
1705        // SAFETY: `checksum` is `width == PLANES * 16` bytes and `block` is a
1706        // non-zero multiple of it, matching every arm's requirement.
1707        unsafe {
1708            match width / 16 {
1709                1 => update_planes::<1>(checksum, block),
1710                2 => update_planes::<2>(checksum, block),
1711                3 => update_planes::<3>(checksum, block),
1712                4 => update_planes::<4>(checksum, block),
1713                _ => return false,
1714            }
1715        }
1716        true
1717    }
1718
1719    /// # Safety
1720    /// `checksum` must be exactly `PLANES * 16` bytes and `block.len()` a
1721    /// non-zero multiple of it.
1722    #[inline(always)]
1723    unsafe fn update_planes<const PLANES: usize>(checksum: &mut [u8], block: &[u8]) {
1724        unsafe {
1725            let mut acc = [vdupq_n_u8(0); PLANES];
1726            for (plane, lane) in acc.iter_mut().enumerate() {
1727                *lane = vld1q_u8(checksum.as_ptr().add(plane * 16));
1728            }
1729            checksum_block::<PLANES>(&mut acc, block.as_ptr(), block.len());
1730            for (plane, lane) in acc.iter().enumerate() {
1731                vst1q_u8(checksum.as_mut_ptr().add(plane * 16), *lane);
1732            }
1733        }
1734    }
1735
1736    /// Selection gate. `WEAVER_PAR2_CKSUM_NEON=0` forces the portable arm,
1737    /// `=1` forces this port; the default is the measured winner.
1738    pub(super) fn enabled() -> bool {
1739        static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1740        *ENABLED.get_or_init(|| std::env::var_os("WEAVER_PAR2_CKSUM_NEON").is_none_or(|v| v != "0"))
1741    }
1742}
1743
1744/// `checksum = gf16_mul2(checksum) XOR (XOR of every `width`-sized region of
1745/// `block`)`, per little-endian u16 lane.
1746///
1747/// Contract-identical to the scalar form it replaces, and to ParPar's
1748/// `gf16_checksum_block` (`parpar/gf16/gf16_checksum_x86.h:24-56`), which is
1749/// likewise "multiply the accumulator by two, then XOR the block in".
1750///
1751/// The previous shape walked one u16 lane at a time and re-strode the whole
1752/// block per lane — `width`/2 passes with a `width`-byte stride, which is both
1753/// unvectorizable and cache-hostile. XOR is associative and commutative, so
1754/// folding the regions in linear order and then applying the per-lane
1755/// multiply-by-two yields the same bytes while touching each input byte once
1756/// in ascending order.
1757fn update_packed_checksum(checksum: &mut [u8], block: &[u8]) {
1758    debug_assert_eq!(checksum.len() % 2, 0);
1759    debug_assert_eq!(block.len() % checksum.len(), 0);
1760    let width = checksum.len();
1761
1762    // Narrow accumulators (Plain's width 2, XOR-JIT's 2 and 4) fold entirely
1763    // inside one register; going through the slice per region instead — as the
1764    // wide path below does — costs more than the vectorization saves.
1765    if width == 2 {
1766        let mut acc = gf16_mul2(u16::from_le_bytes([checksum[0], checksum[1]]));
1767        for region in block.chunks_exact(2) {
1768            acc ^= u16::from_le_bytes([region[0], region[1]]);
1769        }
1770        checksum.copy_from_slice(&acc.to_le_bytes());
1771        return;
1772    }
1773    if width <= 8 {
1774        let mut buf = [0u8; 8];
1775        buf[..width].copy_from_slice(checksum);
1776        let mut acc = gf16_mul2_x4(u64::from_le_bytes(buf));
1777        for region in block.chunks_exact(width) {
1778            let mut r = [0u8; 8];
1779            r[..width].copy_from_slice(region);
1780            acc ^= u64::from_le_bytes(r);
1781        }
1782        checksum.copy_from_slice(&acc.to_le_bytes()[..width]);
1783        return;
1784    }
1785
1786    // Multiply the accumulator by two, eight bytes at a time.
1787    let mut lane = 0usize;
1788    while lane + 8 <= width {
1789        let v = u64::from_le_bytes(checksum[lane..lane + 8].try_into().unwrap());
1790        checksum[lane..lane + 8].copy_from_slice(&gf16_mul2_x4(v).to_le_bytes());
1791        lane += 8;
1792    }
1793    while lane < width {
1794        let v = u16::from_le_bytes([checksum[lane], checksum[lane + 1]]);
1795        checksum[lane..lane + 2].copy_from_slice(&gf16_mul2(v).to_le_bytes());
1796        lane += 2;
1797    }
1798
1799    // Fold every region of the block into the accumulator in linear order.
1800    for region in block.chunks_exact(width) {
1801        let mut i = 0usize;
1802        while i + 8 <= width {
1803            let a = u64::from_le_bytes(checksum[i..i + 8].try_into().unwrap());
1804            let b = u64::from_le_bytes(region[i..i + 8].try_into().unwrap());
1805            checksum[i..i + 8].copy_from_slice(&(a ^ b).to_le_bytes());
1806            i += 8;
1807        }
1808        while i < width {
1809            checksum[i] ^= region[i];
1810            i += 1;
1811        }
1812    }
1813}
1814
1815/// Fold `data` into a zero-initialized `checksum_width`-byte accumulator,
1816/// block by block. Shared by the writer and the verifier so the two can never
1817/// drift.
1818///
1819/// The narrow widths keep their accumulator in a register for the whole sweep
1820/// rather than storing it back after every block — at `checksum_width == 2`
1821/// with `block_len == 2` that store/reload is the entire cost of the loop.
1822fn fold_packed_checksum(data: &[u8], block_len: usize, checksum_width: usize) -> [u8; 64] {
1823    // 16-byte-multiple widths have a faithful ParPar NEON form; see
1824    // [`parpar_neon_checksum`]. Shapes it declines fall through. NOTE: aarch64
1825    // currently only ever reaches width 2 — see that module's "Reachability".
1826    #[cfg(target_arch = "aarch64")]
1827    if parpar_neon_checksum::enabled()
1828        && let Some(out) = parpar_neon_checksum::fold(data, block_len, checksum_width)
1829    {
1830        return out;
1831    }
1832    let mut out = [0u8; 64];
1833    // Each block folds its regions into a fresh register before touching the
1834    // accumulator. That keeps the wide XOR reduction independent of the
1835    // serial multiply-by-two chain, which is what lets a block's lookups issue
1836    // in parallel; accumulating straight into `acc` instead would serialize
1837    // every region behind the previous block's multiply.
1838    if checksum_width == 2 {
1839        let mut acc = 0u16;
1840        for block in data.chunks_exact(block_len) {
1841            let mut folded = 0u16;
1842            for region in block.chunks_exact(2) {
1843                folded ^= u16::from_le_bytes([region[0], region[1]]);
1844            }
1845            acc = gf16_mul2(acc) ^ folded;
1846        }
1847        out[..2].copy_from_slice(&acc.to_le_bytes());
1848        return out;
1849    }
1850    if checksum_width == 4 {
1851        let mut acc = 0u64;
1852        for block in data.chunks_exact(block_len) {
1853            let mut folded = 0u64;
1854            for region in block.chunks_exact(4) {
1855                folded ^= u32::from_le_bytes(region.try_into().unwrap()) as u64;
1856            }
1857            acc = gf16_mul2_x4(acc) ^ folded;
1858        }
1859        out[..4].copy_from_slice(&acc.to_le_bytes()[..4]);
1860        return out;
1861    }
1862    if checksum_width == 8 {
1863        let mut acc = 0u64;
1864        for block in data.chunks_exact(block_len) {
1865            let mut folded = 0u64;
1866            for region in block.chunks_exact(8) {
1867                folded ^= u64::from_le_bytes(region.try_into().unwrap());
1868            }
1869            acc = gf16_mul2_x4(acc) ^ folded;
1870        }
1871        out[..8].copy_from_slice(&acc.to_le_bytes());
1872        return out;
1873    }
1874    for block in data.chunks_exact(block_len) {
1875        update_packed_checksum(&mut out[..checksum_width], block);
1876    }
1877    out
1878}
1879
1880fn write_packed_checksum(
1881    buffer: &mut [u8],
1882    data_len: usize,
1883    block_len: usize,
1884    checksum_width: usize,
1885) {
1886    debug_assert_eq!(data_len % block_len, 0);
1887    debug_assert!(checksum_width <= 64);
1888    debug_assert!(checksum_width <= block_len);
1889    debug_assert_eq!(block_len % checksum_width, 0);
1890    let (data, checksum_block) = buffer.split_at_mut(data_len);
1891    let checksum_block = &mut checksum_block[..block_len];
1892    checksum_block.fill(0);
1893    let folded = fold_packed_checksum(data, block_len, checksum_width);
1894    checksum_block[..checksum_width].copy_from_slice(&folded[..checksum_width]);
1895}
1896
1897fn packed_checksum_matches(
1898    buffer: &[u8],
1899    data_len: usize,
1900    block_len: usize,
1901    checksum_width: usize,
1902) -> bool {
1903    debug_assert_eq!(data_len % block_len, 0);
1904    debug_assert!(checksum_width <= 64);
1905    debug_assert!(checksum_width <= block_len);
1906    let (data, checksum_block) = buffer.split_at(data_len);
1907    let checksum_block = &checksum_block[..block_len];
1908    let expected = fold_packed_checksum(data, block_len, checksum_width);
1909    checksum_block[..checksum_width] == expected[..checksum_width]
1910        && checksum_block[checksum_width..]
1911            .iter()
1912            .all(|byte| *byte == 0)
1913}
1914
1915struct PrepareBatch {
1916    set: StreamBatchSet,
1917    aligned_len: usize,
1918    chunk_len: usize,
1919    layout: Option<Arc<ControllerLayout>>,
1920}
1921
1922struct PreparedControllerBatch {
1923    set: StreamBatchSet,
1924}
1925
1926struct SubmittedControllerBatch<'a> {
1927    batch: crate::cpu_repair_controller::InputBatch,
1928    ticket: CpuComputeTicket<'a>,
1929}
1930
1931#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1932enum OutputEncoding {
1933    Plain,
1934    CpuEncoded,
1935}
1936
1937#[derive(Clone, Copy)]
1938enum OutputTransferSource {
1939    PlainContiguous(usize),
1940    CpuEncodedChunkInterleaved {
1941        base: usize,
1942        output: usize,
1943        output_count: usize,
1944        chunk_len: usize,
1945    },
1946}
1947
1948impl OutputTransferSource {
1949    fn encoding(self) -> OutputEncoding {
1950        match self {
1951            Self::PlainContiguous(_) => OutputEncoding::Plain,
1952            Self::CpuEncodedChunkInterleaved { .. } => OutputEncoding::CpuEncoded,
1953        }
1954    }
1955}
1956
1957enum OutputTransferLayout<'a> {
1958    PlainContiguous(&'a [usize]),
1959    CpuEncodedChunkInterleaved {
1960        base: usize,
1961        output_count: usize,
1962        chunk_len: usize,
1963    },
1964}
1965
1966impl OutputTransferLayout<'_> {
1967    fn len(&self) -> usize {
1968        match self {
1969            Self::PlainContiguous(outputs) => outputs.len(),
1970            Self::CpuEncodedChunkInterleaved { output_count, .. } => *output_count,
1971        }
1972    }
1973
1974    fn source(&self, output: usize) -> OutputTransferSource {
1975        match self {
1976            Self::PlainContiguous(outputs) => {
1977                OutputTransferSource::PlainContiguous(outputs[output])
1978            }
1979            Self::CpuEncodedChunkInterleaved {
1980                base,
1981                output_count,
1982                chunk_len,
1983            } => OutputTransferSource::CpuEncodedChunkInterleaved {
1984                base: *base,
1985                output,
1986                output_count: *output_count,
1987                chunk_len: *chunk_len,
1988            },
1989        }
1990    }
1991}
1992
1993enum PreparationMessage {
1994    Begin(PrepareBatch),
1995    Input {
1996        lane: usize,
1997        coefficients: Vec<u16>,
1998        buffer: TransferBuffer,
1999        submitted: Option<crate::cpu_repair_controller::InputBatch>,
2000    },
2001    Flush {
2002        batch: crate::cpu_repair_controller::InputBatch,
2003    },
2004    #[cfg(target_arch = "x86_64")]
2005    RecycleJit {
2006        staging_area: usize,
2007        batch: reedsolomon_rs::xor_jit::packed::PackedJitBatch,
2008    },
2009    FinishOutput {
2010        index: usize,
2011        source: OutputTransferSource,
2012        aligned_len: usize,
2013        buffer: TransferBuffer,
2014    },
2015}
2016
2017/// One of two controller-owned transfer buffers. The slot identity survives
2018/// preparation and output finishing so a completion cannot duplicate or
2019/// replace a checked-out buffer.
2020struct TransferBuffer {
2021    slot: usize,
2022    bytes: Vec<u8>,
2023}
2024
2025struct FinishedOutput {
2026    index: usize,
2027    buffer: TransferBuffer,
2028    checksum_valid: bool,
2029    elapsed: Duration,
2030}
2031
2032struct CpuInputPreparer<'a> {
2033    command_tx: std::sync::mpsc::SyncSender<PreparationMessage>,
2034    complete_rx: std::sync::mpsc::Receiver<TransferBuffer>,
2035    prepared_rx: std::sync::mpsc::Receiver<PreparedControllerBatch>,
2036    submitted_rx:
2037        std::sync::mpsc::Receiver<std::result::Result<SubmittedControllerBatch<'a>, String>>,
2038    finished_rx: std::sync::mpsc::Receiver<FinishedOutput>,
2039    transfer_buffers: [Option<TransferBuffer>; 2],
2040    transfer_buffer_len: usize,
2041    /// The preparation and compute worker loops, when this target cannot spawn
2042    /// them (see [`InlineControllerWorkers`]). Absent on native — the field
2043    /// itself does not exist there — and `None` on wasm targets that can spawn.
2044    #[cfg(target_family = "wasm")]
2045    inline: Option<std::cell::RefCell<InlineControllerWorkers<'a>>>,
2046}
2047
2048impl<'a> CpuInputPreparer<'a> {
2049    /// Give the inline worker loops a turn.
2050    ///
2051    /// Compiles to nothing at all off wasm: the field it reads does not exist
2052    /// there, so every call site keeps the shape it had when the workers were
2053    /// unconditionally spawned.
2054    #[inline(always)]
2055    fn pump(&self) {
2056        #[cfg(target_family = "wasm")]
2057        if let Some(inline) = self.inline.as_ref() {
2058            inline.borrow_mut().run();
2059        }
2060    }
2061
2062    /// Send one command to the preparation worker.
2063    ///
2064    /// Off the inline path this is `command_tx.send`. On it, the pump is what
2065    /// keeps the command queue at depth one, which is what makes the existing
2066    /// channel capacities sufficient for single-threaded execution.
2067    // The signature deliberately mirrors `SyncSender::send`, which this replaced
2068    // at every call site: each caller discards the undelivered message with
2069    // `.map_err(|_| ...)` exactly as it did before, so the large `Err` never
2070    // outlives the call and the generated code is unchanged.
2071    #[allow(clippy::result_large_err)]
2072    fn send_command(
2073        &self,
2074        message: PreparationMessage,
2075    ) -> std::result::Result<(), std::sync::mpsc::SendError<PreparationMessage>> {
2076        let result = self.command_tx.send(message);
2077        self.pump();
2078        result
2079    }
2080
2081    /// Whether the inline preparation worker unwound — the inline counterpart of
2082    /// the spawned worker's `join().is_err()`. Always `false` when it was
2083    /// spawned, because that case is reported by the join itself.
2084    #[cfg(target_family = "wasm")]
2085    fn inline_preparation_panicked(&self) -> bool {
2086        self.inline
2087            .as_ref()
2088            .is_some_and(|inline| inline.borrow().preparation_panicked)
2089    }
2090
2091    fn take_transfer_buffer(
2092        &mut self,
2093        cancel: Option<&CancellationToken>,
2094    ) -> Result<TransferBuffer> {
2095        if let Some(buffer) = self.transfer_buffers.iter_mut().find_map(Option::take) {
2096            return Ok(buffer);
2097        }
2098
2099        self.pump();
2100        let buffer = recv_with_cancel(
2101            &self.complete_rx,
2102            cancel,
2103            "CPU repair preparation worker stopped unexpectedly",
2104        )?;
2105        self.validate_transfer_buffer(&buffer)?;
2106        Ok(buffer)
2107    }
2108
2109    fn return_transfer_buffer(&mut self, buffer: TransferBuffer) -> Result<()> {
2110        self.validate_transfer_buffer(&buffer)?;
2111        let slot = buffer.slot;
2112        let Some(destination) = self.transfer_buffers.get_mut(slot) else {
2113            return Err(Par2Error::ReedSolomonError {
2114                reason: format!("CPU repair transfer buffer returned unknown slot {slot}"),
2115            });
2116        };
2117        if destination.is_some() {
2118            return Err(Par2Error::ReedSolomonError {
2119                reason: format!("CPU repair transfer buffer slot {slot} was returned twice"),
2120            });
2121        }
2122        *destination = Some(buffer);
2123        Ok(())
2124    }
2125
2126    fn restore_transfer_buffers(&mut self, cancel: Option<&CancellationToken>) -> Result<()> {
2127        while self.transfer_buffers.iter().any(Option::is_none) {
2128            self.pump();
2129            let buffer = recv_with_cancel(
2130                &self.complete_rx,
2131                cancel,
2132                "CPU repair preparation worker stopped unexpectedly",
2133            )?;
2134            self.return_transfer_buffer(buffer)?;
2135        }
2136        Ok(())
2137    }
2138
2139    fn validate_transfer_buffer(&self, buffer: &TransferBuffer) -> Result<()> {
2140        if buffer.slot >= self.transfer_buffers.len() {
2141            return Err(Par2Error::ReedSolomonError {
2142                reason: format!(
2143                    "CPU repair transfer buffer returned unknown slot {}",
2144                    buffer.slot
2145                ),
2146            });
2147        }
2148        if buffer.bytes.len() != self.transfer_buffer_len {
2149            return Err(Par2Error::ReedSolomonError {
2150                reason: format!(
2151                    "CPU repair transfer buffer slot {} has {} bytes; expected {}",
2152                    buffer.slot,
2153                    buffer.bytes.len(),
2154                    self.transfer_buffer_len
2155                ),
2156            });
2157        }
2158        Ok(())
2159    }
2160}
2161
2162fn finalize_output_bytes(
2163    kernel: CpuKernelKind,
2164    method: CpuMethodContract,
2165    encoding: OutputEncoding,
2166    buffer: &mut [u8],
2167) -> bool {
2168    match encoding {
2169        OutputEncoding::Plain => return true,
2170        OutputEncoding::CpuEncoded => {}
2171    }
2172
2173    match kernel {
2174        #[cfg(target_arch = "x86_64")]
2175        CpuKernelKind::XorJit(width) => {
2176            let block = width.block_bytes();
2177            debug_assert!(buffer.len().is_multiple_of(block));
2178            for bytes in buffer.chunks_exact_mut(block) {
2179                // SAFETY: XOR-JIT selection proves the required CPU features;
2180                // the controller provides one complete JIT block at a time.
2181                unsafe { width.finish_block(bytes) };
2182            }
2183        }
2184        CpuKernelKind::Folded => crate::gf_simd::altmap_decode(buffer),
2185        // Identity staging has no inverse to apply — see the variant's note.
2186        #[cfg(target_arch = "aarch64")]
2187        CpuKernelKind::NeonPacked => {}
2188        CpuKernelKind::Plain => {}
2189    }
2190
2191    packed_checksum_matches(
2192        buffer,
2193        buffer.len() - method.stride,
2194        method.stride,
2195        method.checksum_width,
2196    )
2197}
2198
2199/// The preparation worker's loop *body* and the state it carries across
2200/// messages, factored out of [`run_preparation_worker`] so the identical body
2201/// can also run inline on the controller thread where no worker thread can be
2202/// spawned, as on WebAssembly without thread support.
2203struct PreparationWorker<'a> {
2204    complete_tx: std::sync::mpsc::SyncSender<TransferBuffer>,
2205    prepared_tx: std::sync::mpsc::SyncSender<PreparedControllerBatch>,
2206    submitted_tx:
2207        std::sync::mpsc::SyncSender<std::result::Result<SubmittedControllerBatch<'a>, String>>,
2208    finished_tx: std::sync::mpsc::SyncSender<FinishedOutput>,
2209    kernel: CpuKernelKind,
2210    method: CpuMethodContract,
2211    output_base: usize,
2212    output_count: usize,
2213    memo: &'a PreparedFactorMemo,
2214    #[cfg(target_arch = "x86_64")]
2215    jit_memo: Option<&'a JitMemo>,
2216    timings: &'a CpuControllerTimings,
2217    compute_submitter: CpuComputeSubmitter<'a>,
2218    trace: ControllerExecutionTrace,
2219    active: Option<PrepareBatch>,
2220    #[cfg(target_arch = "x86_64")]
2221    jit_workspaces: [reedsolomon_rs::xor_jit::packed::PackedJitWorkspace; 2],
2222}
2223
2224impl<'a> PreparationWorker<'a> {
2225    #[allow(clippy::too_many_arguments)]
2226    fn new(
2227        complete_tx: std::sync::mpsc::SyncSender<TransferBuffer>,
2228        prepared_tx: std::sync::mpsc::SyncSender<PreparedControllerBatch>,
2229        submitted_tx: std::sync::mpsc::SyncSender<
2230            std::result::Result<SubmittedControllerBatch<'a>, String>,
2231        >,
2232        finished_tx: std::sync::mpsc::SyncSender<FinishedOutput>,
2233        kernel: CpuKernelKind,
2234        method: CpuMethodContract,
2235        output_base: usize,
2236        output_count: usize,
2237        memo: &'a PreparedFactorMemo,
2238        #[cfg(target_arch = "x86_64")] jit_memo: Option<&'a JitMemo>,
2239        timings: &'a CpuControllerTimings,
2240        compute_submitter: CpuComputeSubmitter<'a>,
2241        trace: ControllerExecutionTrace,
2242    ) -> Self {
2243        Self {
2244            complete_tx,
2245            prepared_tx,
2246            submitted_tx,
2247            finished_tx,
2248            kernel,
2249            method,
2250            output_base,
2251            output_count,
2252            memo,
2253            #[cfg(target_arch = "x86_64")]
2254            jit_memo,
2255            timings,
2256            compute_submitter,
2257            trace,
2258            active: None,
2259            #[cfg(target_arch = "x86_64")]
2260            jit_workspaces: [
2261                reedsolomon_rs::xor_jit::packed::PackedJitWorkspace::default(),
2262                reedsolomon_rs::xor_jit::packed::PackedJitWorkspace::default(),
2263            ],
2264        }
2265    }
2266
2267    /// Handle one command. `Break` reproduces every `break` arm of the original
2268    /// loop; the worker is finished and must release its channel endpoints.
2269    fn step(&mut self, message: PreparationMessage) -> ControlFlow<()> {
2270        match message {
2271            PreparationMessage::Begin(batch) => {
2272                debug_assert!(self.active.is_none());
2273                self.active = Some(batch);
2274            }
2275            PreparationMessage::Input {
2276                lane,
2277                coefficients,
2278                mut buffer,
2279                submitted,
2280            } => {
2281                let Some(batch) = self.active.as_mut() else {
2282                    self.trace.record(ControllerExecutionEvent::Failed {
2283                        phase: ControllerFailurePhase::Prepare,
2284                    });
2285                    return ControlFlow::Break(());
2286                };
2287                if coefficients.len() != batch.set.coefficients.len() / batch.set.input_grouping {
2288                    self.trace.record(ControllerExecutionEvent::Failed {
2289                        phase: ControllerFailurePhase::Prepare,
2290                    });
2291                    return ControlFlow::Break(());
2292                }
2293                for (output, coefficient) in coefficients.into_iter().enumerate() {
2294                    batch.set.coefficients[output * batch.set.input_grouping + lane] = coefficient;
2295                }
2296                let checksum_block_len = self.method.stride;
2297                write_packed_checksum(
2298                    &mut buffer.bytes[..batch.aligned_len],
2299                    batch.aligned_len - checksum_block_len,
2300                    checksum_block_len,
2301                    self.method.checksum_width,
2302                );
2303                prepare_stream_source(
2304                    &mut batch.set,
2305                    lane,
2306                    &buffer.bytes,
2307                    batch.aligned_len,
2308                    batch.chunk_len,
2309                    self.kernel,
2310                );
2311                let mut stop_after_buffer = false;
2312                if let Some(submitted) = submitted {
2313                    let mut batch = self.active.take().expect("active preparation batch");
2314                    if submitted.input_len != lane + 1
2315                        || submitted.staging_area >= 2
2316                        || submitted.input_start != batch.set.start
2317                    {
2318                        self.trace.record(ControllerExecutionEvent::Failed {
2319                            phase: ControllerFailurePhase::Prepare,
2320                        });
2321                        return ControlFlow::Break(());
2322                    }
2323                    batch.set.len = submitted.input_len;
2324                    let submitted_info = submitted;
2325                    if batch.layout.is_some() {
2326                        let submitted = submit_prepared_controller_batch(
2327                            submitted,
2328                            batch,
2329                            self.output_base,
2330                            self.output_count,
2331                            self.memo,
2332                            #[cfg(target_arch = "x86_64")]
2333                            self.jit_memo,
2334                            #[cfg(target_arch = "x86_64")]
2335                            &mut self.jit_workspaces,
2336                            self.method,
2337                            self.timings,
2338                            &self.trace,
2339                            &mut self.compute_submitter,
2340                        );
2341                        stop_after_buffer = submitted.is_err();
2342                        if self.submitted_tx.send(submitted).is_err() {
2343                            self.trace.record(ControllerExecutionEvent::Failed {
2344                                phase: ControllerFailurePhase::Prepare,
2345                            });
2346                            return ControlFlow::Break(());
2347                        }
2348                    } else if self
2349                        .prepared_tx
2350                        .send(PreparedControllerBatch { set: batch.set })
2351                        .is_err()
2352                    {
2353                        self.trace.record(ControllerExecutionEvent::Failed {
2354                            phase: ControllerFailurePhase::Prepare,
2355                        });
2356                        return ControlFlow::Break(());
2357                    }
2358                    if !stop_after_buffer {
2359                        self.trace
2360                            .record(ControllerExecutionEvent::PreparationCompleted {
2361                                staging_area: submitted_info.staging_area,
2362                                input_len: submitted_info.input_len,
2363                            });
2364                    }
2365                }
2366                // Launch the completed group before resolving the input future;
2367                // compute is already queued when this buffer is returned.
2368                if self.complete_tx.send(buffer).is_err() {
2369                    self.trace.record(ControllerExecutionEvent::Failed {
2370                        phase: ControllerFailurePhase::Prepare,
2371                    });
2372                    return ControlFlow::Break(());
2373                }
2374                if stop_after_buffer {
2375                    return ControlFlow::Break(());
2376                }
2377            }
2378            PreparationMessage::Flush { batch: submitted } => {
2379                let Some(mut batch) = self.active.take() else {
2380                    self.trace.record(ControllerExecutionEvent::Failed {
2381                        phase: ControllerFailurePhase::Prepare,
2382                    });
2383                    return ControlFlow::Break(());
2384                };
2385                if submitted.input_len == 0
2386                    || submitted.input_len > batch.set.input_grouping
2387                    || submitted.staging_area >= 2
2388                    || submitted.input_start != batch.set.start
2389                {
2390                    self.trace.record(ControllerExecutionEvent::Failed {
2391                        phase: ControllerFailurePhase::Prepare,
2392                    });
2393                    return ControlFlow::Break(());
2394                }
2395                batch.set.len = submitted.input_len;
2396                let submitted_info = submitted;
2397                let submitted = submit_prepared_controller_batch(
2398                    submitted,
2399                    batch,
2400                    self.output_base,
2401                    self.output_count,
2402                    self.memo,
2403                    #[cfg(target_arch = "x86_64")]
2404                    self.jit_memo,
2405                    #[cfg(target_arch = "x86_64")]
2406                    &mut self.jit_workspaces,
2407                    self.method,
2408                    self.timings,
2409                    &self.trace,
2410                    &mut self.compute_submitter,
2411                );
2412                let submit_failed = submitted.is_err();
2413                if self.submitted_tx.send(submitted).is_err() {
2414                    self.trace.record(ControllerExecutionEvent::Failed {
2415                        phase: ControllerFailurePhase::Prepare,
2416                    });
2417                    return ControlFlow::Break(());
2418                }
2419                if submit_failed {
2420                    return ControlFlow::Break(());
2421                }
2422                self.trace
2423                    .record(ControllerExecutionEvent::PreparationCompleted {
2424                        staging_area: submitted_info.staging_area,
2425                        input_len: submitted_info.input_len,
2426                    });
2427            }
2428            #[cfg(target_arch = "x86_64")]
2429            PreparationMessage::RecycleJit {
2430                staging_area,
2431                batch,
2432            } => {
2433                if staging_area >= self.jit_workspaces.len()
2434                    || self.jit_workspaces[staging_area].recycle(batch).is_err()
2435                {
2436                    self.trace.record(ControllerExecutionEvent::Failed {
2437                        phase: ControllerFailurePhase::Compute,
2438                    });
2439                    return ControlFlow::Break(());
2440                }
2441            }
2442            PreparationMessage::FinishOutput {
2443                index,
2444                source,
2445                aligned_len,
2446                mut buffer,
2447            } => {
2448                let started = Instant::now();
2449                debug_assert!(self.active.is_none());
2450                let encoding = source.encoding();
2451                // SAFETY: output storage stays fixed until every queued finish
2452                // operation has completed and the transfer worker is joined.
2453                match source {
2454                    OutputTransferSource::PlainContiguous(source) => {
2455                        let source =
2456                            unsafe { std::slice::from_raw_parts(source as *const u8, aligned_len) };
2457                        buffer.bytes[..aligned_len].copy_from_slice(source);
2458                    }
2459                    OutputTransferSource::CpuEncodedChunkInterleaved {
2460                        base,
2461                        output,
2462                        output_count,
2463                        chunk_len,
2464                    } => {
2465                        let source = unsafe {
2466                            std::slice::from_raw_parts(
2467                                base as *const u8,
2468                                aligned_len.saturating_mul(output_count),
2469                            )
2470                        };
2471                        for chunk_start in (0..aligned_len).step_by(chunk_len) {
2472                            let len = (aligned_len - chunk_start).min(chunk_len);
2473                            let source_start = chunk_start * output_count + output * len;
2474                            buffer.bytes[chunk_start..chunk_start + len]
2475                                .copy_from_slice(&source[source_start..source_start + len]);
2476                        }
2477                    }
2478                }
2479                let checksum_valid = finalize_output_bytes(
2480                    self.kernel,
2481                    self.method,
2482                    encoding,
2483                    &mut buffer.bytes[..aligned_len],
2484                );
2485                if self
2486                    .finished_tx
2487                    .send(FinishedOutput {
2488                        index,
2489                        buffer,
2490                        checksum_valid,
2491                        elapsed: started.elapsed(),
2492                    })
2493                    .is_err()
2494                {
2495                    self.trace.record(ControllerExecutionEvent::Failed {
2496                        phase: ControllerFailurePhase::OutputTransfer,
2497                    });
2498                    return ControlFlow::Break(());
2499                }
2500            }
2501        }
2502        ControlFlow::Continue(())
2503    }
2504}
2505
2506#[allow(clippy::too_many_arguments)]
2507fn run_preparation_worker<'a>(
2508    command_rx: std::sync::mpsc::Receiver<PreparationMessage>,
2509    complete_tx: std::sync::mpsc::SyncSender<TransferBuffer>,
2510    prepared_tx: std::sync::mpsc::SyncSender<PreparedControllerBatch>,
2511    submitted_tx: std::sync::mpsc::SyncSender<
2512        std::result::Result<SubmittedControllerBatch<'a>, String>,
2513    >,
2514    finished_tx: std::sync::mpsc::SyncSender<FinishedOutput>,
2515    kernel: CpuKernelKind,
2516    method: CpuMethodContract,
2517    output_base: usize,
2518    output_count: usize,
2519    memo: &'a PreparedFactorMemo,
2520    #[cfg(target_arch = "x86_64")] jit_memo: Option<&'a JitMemo>,
2521    timings: &'a CpuControllerTimings,
2522    compute_submitter: CpuComputeSubmitter<'a>,
2523    trace: ControllerExecutionTrace,
2524) {
2525    let mut worker = PreparationWorker::new(
2526        complete_tx,
2527        prepared_tx,
2528        submitted_tx,
2529        finished_tx,
2530        kernel,
2531        method,
2532        output_base,
2533        output_count,
2534        memo,
2535        #[cfg(target_arch = "x86_64")]
2536        jit_memo,
2537        timings,
2538        compute_submitter,
2539        trace,
2540    );
2541    while let Ok(message) = command_rx.recv() {
2542        if worker.step(message).is_break() {
2543            break;
2544        }
2545    }
2546}
2547
2548/// Run the preparation worker and report whether it panicked — the value the
2549/// controller's `join()` interprets. Factored out of the `scope.spawn` closure
2550/// only so the two spawning arms below can share one definition.
2551#[allow(clippy::too_many_arguments)]
2552fn run_guarded_preparation_worker<'a>(
2553    command_rx: std::sync::mpsc::Receiver<PreparationMessage>,
2554    complete_tx: std::sync::mpsc::SyncSender<TransferBuffer>,
2555    prepared_tx: std::sync::mpsc::SyncSender<PreparedControllerBatch>,
2556    submitted_tx: std::sync::mpsc::SyncSender<
2557        std::result::Result<SubmittedControllerBatch<'a>, String>,
2558    >,
2559    finished_tx: std::sync::mpsc::SyncSender<FinishedOutput>,
2560    kernel: CpuKernelKind,
2561    method: CpuMethodContract,
2562    output_base: usize,
2563    output_count: usize,
2564    memo: &'a PreparedFactorMemo,
2565    #[cfg(target_arch = "x86_64")] jit_memo: Option<&'a JitMemo>,
2566    timings: &'a CpuControllerTimings,
2567    compute_submitter: CpuComputeSubmitter<'a>,
2568    trace: ControllerExecutionTrace,
2569) -> bool {
2570    let panic_trace = trace.clone();
2571    if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2572        run_preparation_worker(
2573            command_rx,
2574            complete_tx,
2575            prepared_tx,
2576            submitted_tx,
2577            finished_tx,
2578            kernel,
2579            method,
2580            output_base,
2581            output_count,
2582            memo,
2583            #[cfg(target_arch = "x86_64")]
2584            jit_memo,
2585            timings,
2586            compute_submitter,
2587            trace,
2588        );
2589    }))
2590    .is_err()
2591    {
2592        panic_trace.record(ControllerExecutionEvent::Failed {
2593            phase: ControllerFailurePhase::Prepare,
2594        });
2595        true
2596    } else {
2597        false
2598    }
2599}
2600
2601// ── Inline (spawn-less) controller execution ───────────────────────────────
2602//
2603// `wasm32-wasip1` cannot spawn a thread at all: `std::thread::Builder::spawn`
2604// returns `Unsupported` (errno 58), so the `std::thread::scope` the streamed
2605// controller is built on aborts the process before any repair arithmetic runs.
2606// There, the controller keeps its exact shape — same staging areas, same
2607// channels, same backpressure accounting, same cancellation checks — and runs
2608// the preparation and compute worker *loop bodies* (`PreparationWorker::step`
2609// and `ComputeWorker::step`, the very ones the spawned workers run) on the
2610// controller thread, pumped from every point where the controller touches a
2611// worker channel. It is the same repair path at width one, not a second one.
2612//
2613// Why the existing bounded channels cannot deadlock when both ends are on one
2614// thread:
2615//
2616//  * Every controller-thread send goes through `CpuInputPreparer::send_command`,
2617//    which pumps immediately afterwards, and every controller-thread receive is
2618//    preceded by a pump. At most one command is therefore queued when the pump
2619//    runs, so one pump produces at most one item per preparation→controller
2620//    channel — within the capacity of even the tightest of them (`submitted`,
2621//    capacity 1).
2622//  * `run_ready_compute` empties every compute worker's job queue *before* the
2623//    preparation worker is handed its next command, so a submission of one job
2624//    per worker always fits the per-worker capacity of 1.
2625//  * The pumped graph is acyclic — commands feed preparation, preparation feeds
2626//    compute, compute feeds completions, and only the controller thread
2627//    consumes what comes back — so a pump always runs out of work and returns.
2628//
2629// A worker that stops drops its channel endpoints, exactly as a worker thread
2630// returning from its loop does, so the controller sees the same `Disconnected`
2631// failures it already handles instead of waiting forever.
2632#[cfg(target_family = "wasm")]
2633struct InlineComputeWorker<'a> {
2634    receiver: std::sync::mpsc::Receiver<CpuComputeJob<'a>>,
2635    completion_tx: std::sync::mpsc::SyncSender<CpuComputeCompletion>,
2636    state: ComputeWorker,
2637}
2638
2639#[cfg(target_family = "wasm")]
2640impl<'a> InlineComputeWorker<'a> {
2641    fn new(
2642        worker: usize,
2643        receiver: std::sync::mpsc::Receiver<CpuComputeJob<'a>>,
2644        completion_tx: std::sync::mpsc::SyncSender<CpuComputeCompletion>,
2645    ) -> Self {
2646        Self {
2647            receiver,
2648            completion_tx,
2649            state: ComputeWorker::new(worker),
2650        }
2651    }
2652}
2653
2654#[cfg(target_family = "wasm")]
2655struct InlineControllerWorkers<'a> {
2656    /// Dropped together with `preparation` when the preparation worker stops,
2657    /// so the controller's next `command_tx.send` fails just as it would if the
2658    /// worker thread had returned.
2659    command_rx: Option<std::sync::mpsc::Receiver<PreparationMessage>>,
2660    /// `None` once stopped; dropping it releases every preparation-side sender.
2661    preparation: Option<PreparationWorker<'a>>,
2662    /// One slot per compute worker; `None` once that worker has stopped.
2663    compute: Vec<Option<InlineComputeWorker<'a>>>,
2664    /// The inline equivalent of the preparation worker's `join().is_err()`.
2665    preparation_panicked: bool,
2666}
2667
2668#[cfg(target_family = "wasm")]
2669impl<'a> InlineControllerWorkers<'a> {
2670    fn new(
2671        command_rx: std::sync::mpsc::Receiver<PreparationMessage>,
2672        preparation: PreparationWorker<'a>,
2673        compute: Vec<InlineComputeWorker<'a>>,
2674    ) -> Self {
2675        Self {
2676            command_rx: Some(command_rx),
2677            preparation: Some(preparation),
2678            compute: compute.into_iter().map(Some).collect(),
2679            preparation_panicked: false,
2680        }
2681    }
2682
2683    /// Give every worker loop a turn, until none of them can make progress.
2684    fn run(&mut self) {
2685        loop {
2686            self.run_ready_compute();
2687            let message = match self.command_rx.as_ref() {
2688                Some(command_rx) => match command_rx.try_recv() {
2689                    Ok(message) => message,
2690                    Err(_) => break,
2691                },
2692                None => return,
2693            };
2694            let Some(preparation) = self.preparation.as_mut() else {
2695                return;
2696            };
2697            let panic_trace = preparation.trace.clone();
2698            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2699                preparation.step(message)
2700            })) {
2701                Ok(ControlFlow::Continue(())) => {}
2702                Ok(ControlFlow::Break(())) => {
2703                    self.stop_preparation();
2704                    break;
2705                }
2706                Err(_) => {
2707                    // Identical to what `run_guarded_preparation_worker` records
2708                    // when the spawned worker unwinds.
2709                    panic_trace.record(ControllerExecutionEvent::Failed {
2710                        phase: ControllerFailurePhase::Prepare,
2711                    });
2712                    self.preparation_panicked = true;
2713                    self.stop_preparation();
2714                    break;
2715                }
2716            }
2717        }
2718        self.run_ready_compute();
2719    }
2720
2721    /// Run every queued compute job. Emptying the queues here is what keeps the
2722    /// per-worker channel capacity of 1 sufficient for the next submission.
2723    fn run_ready_compute(&mut self) {
2724        for slot in &mut self.compute {
2725            let Some(worker) = slot.as_mut() else {
2726                continue;
2727            };
2728            while let Ok(job) = worker.receiver.try_recv() {
2729                if worker.state.step(job, &worker.completion_tx).is_break() {
2730                    *slot = None;
2731                    break;
2732                }
2733            }
2734        }
2735    }
2736
2737    fn stop_preparation(&mut self) {
2738        self.preparation = None;
2739        self.command_rx = None;
2740    }
2741}
2742
2743fn queue_output_finish(
2744    preparer: &mut CpuInputPreparer,
2745    index: usize,
2746    source: OutputTransferSource,
2747    aligned_len: usize,
2748    buffer: TransferBuffer,
2749    trace: &ControllerExecutionTrace,
2750) -> Result<()> {
2751    preparer
2752        .send_command(PreparationMessage::FinishOutput {
2753            index,
2754            source,
2755            aligned_len,
2756            buffer,
2757        })
2758        .map_err(|_| {
2759            trace.record(ControllerExecutionEvent::Failed {
2760                phase: ControllerFailurePhase::OutputTransfer,
2761            });
2762            Par2Error::ReedSolomonError {
2763                reason: "CPU repair transfer worker stopped unexpectedly".to_string(),
2764            }
2765        })
2766}
2767
2768#[allow(clippy::too_many_arguments)]
2769fn finish_and_write_stream_outputs(
2770    preparer: &mut CpuInputPreparer,
2771    outputs: OutputTransferLayout<'_>,
2772    aligned_len: usize,
2773    byte_start: usize,
2774    byte_len: usize,
2775    write_targets: &[RepairWriteTarget],
2776    file_access: &mut dyn FileAccess,
2777    options: &RepairOptions,
2778    #[cfg_attr(not(target_arch = "x86_64"), allow(unused_variables))]
2779    timings: &CpuControllerTimings,
2780    trace: &ControllerExecutionTrace,
2781) -> Result<()> {
2782    debug_assert_eq!(outputs.len(), write_targets.len());
2783    let initially_queued = outputs.len().min(2);
2784    for index in 0..initially_queued {
2785        let buffer = preparer.take_transfer_buffer(options.cancel.as_ref())?;
2786        queue_output_finish(
2787            preparer,
2788            index,
2789            outputs.source(index),
2790            aligned_len,
2791            buffer,
2792            trace,
2793        )?;
2794        trace.record(ControllerExecutionEvent::OutputTransferQueued { output: index });
2795    }
2796
2797    let mut next_to_queue = initially_queued;
2798    for expected in 0..outputs.len() {
2799        preparer.pump();
2800        let FinishedOutput {
2801            index,
2802            buffer,
2803            checksum_valid,
2804            elapsed,
2805        } = recv_with_cancel(
2806            &preparer.finished_rx,
2807            options.cancel.as_ref(),
2808            "CPU repair transfer worker stopped unexpectedly",
2809        )
2810        .inspect_err(|_| {
2811            trace.record(ControllerExecutionEvent::Failed {
2812                phase: ControllerFailurePhase::OutputTransfer,
2813            });
2814        })?;
2815        if index != expected {
2816            return Err(Par2Error::ReedSolomonError {
2817                reason: "CPU repair output transfer arrived out of order".to_string(),
2818            });
2819        }
2820        CpuControllerTimings::record(&timings.finish_ns, elapsed);
2821        check_cancel(options)?;
2822        if !checksum_valid {
2823            trace.record(ControllerExecutionEvent::Failed {
2824                phase: ControllerFailurePhase::OutputTransfer,
2825            });
2826            preparer.return_transfer_buffer(buffer)?;
2827            return Err(Par2Error::ReedSolomonError {
2828                reason: format!("CPU repair output {index} failed its packed checksum"),
2829            });
2830        }
2831
2832        let target = &write_targets[index];
2833        let write_offset = target.offset + byte_start as u64;
2834        let remaining = target.file_end.saturating_sub(write_offset);
2835        let write_len = remaining.min(byte_len as u64) as usize;
2836        let write_started = Instant::now();
2837        if write_len != 0
2838            && let Err(error) = file_access.write_file_range(
2839                &target.file_id,
2840                write_offset,
2841                &buffer.bytes[..write_len],
2842            )
2843        {
2844            trace.record(ControllerExecutionEvent::Failed {
2845                phase: ControllerFailurePhase::Write,
2846            });
2847            return Err(Par2Error::RepairWriteFailed {
2848                filename: target.filename.clone(),
2849                offset: write_offset,
2850                source: error,
2851            });
2852        }
2853        CpuControllerTimings::record(&timings.write_ns, write_started.elapsed());
2854        trace.record(ControllerExecutionEvent::OutputWritten { output: index });
2855
2856        if next_to_queue < outputs.len() {
2857            queue_output_finish(
2858                preparer,
2859                next_to_queue,
2860                outputs.source(next_to_queue),
2861                aligned_len,
2862                buffer,
2863                trace,
2864            )?;
2865            trace.record(ControllerExecutionEvent::OutputTransferQueued {
2866                output: next_to_queue,
2867            });
2868            next_to_queue += 1;
2869        } else {
2870            preparer.return_transfer_buffer(buffer)?;
2871        }
2872    }
2873    Ok(())
2874}
2875
2876/// Read one complete GPU input group through the transfer worker. The CPU
2877/// controller uses the live per-source dispatcher below instead.
2878#[allow(clippy::too_many_arguments)]
2879fn fill_gpu_stream_batch(
2880    preparer: &mut CpuInputPreparer,
2881    mut set: StreamBatchSet,
2882    plan: &RepairPlan,
2883    par2_set: &Par2FileSet,
2884    file_access: &mut dyn FileAccess,
2885    recovery_files: &mut HashMap<PathBuf, File>,
2886    source_reader: &mut Option<StreamSourceReader>,
2887    available_inputs: usize,
2888    batch_start: usize,
2889    batch_len: usize,
2890    byte_start: usize,
2891    byte_len: usize,
2892    aligned_len: usize,
2893    chunk_len: usize,
2894    chunk_count: usize,
2895    options: &RepairOptions,
2896    timings: &CpuControllerTimings,
2897) -> Result<StreamBatchSet> {
2898    set.start = batch_start;
2899    set.len = batch_len;
2900    set.packed_stride = chunk_len;
2901    debug_assert_eq!(chunk_count, aligned_len.div_ceil(chunk_len));
2902    set.coefficients.fill(0);
2903
2904    let started = Instant::now();
2905    preparer
2906        .send_command(PreparationMessage::Begin(PrepareBatch {
2907            set,
2908            aligned_len,
2909            chunk_len,
2910            layout: None,
2911        }))
2912        .map_err(|_| Par2Error::ReedSolomonError {
2913            reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
2914        })?;
2915    for lane in 0..batch_len {
2916        check_cancel(options)?;
2917        let mut buffer = preparer.take_transfer_buffer(options.cancel.as_ref())?;
2918        read_stream_source_chunk(
2919            plan,
2920            par2_set,
2921            file_access,
2922            recovery_files,
2923            source_reader,
2924            available_inputs,
2925            batch_start + lane,
2926            byte_start,
2927            &mut buffer.bytes[..byte_len],
2928        )?;
2929        buffer.bytes[byte_len..aligned_len].fill(0);
2930        let coefficients = (0..plan.input_factors.rows)
2931            .map(|output| plan.input_factors.get(output, batch_start + lane))
2932            .collect();
2933        preparer
2934            .send_command(PreparationMessage::Input {
2935                lane,
2936                coefficients,
2937                buffer,
2938                submitted: (lane + 1 == batch_len).then_some(
2939                    crate::cpu_repair_controller::InputBatch {
2940                        staging_area: 0,
2941                        input_start: batch_start,
2942                        input_len: batch_len,
2943                        add: false,
2944                        reason: crate::cpu_repair_controller::BatchSubmitReason::GroupFull,
2945                    },
2946                ),
2947            })
2948            .map_err(|_| Par2Error::ReedSolomonError {
2949                reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
2950            })?;
2951    }
2952    preparer.restore_transfer_buffers(options.cancel.as_ref())?;
2953    preparer.pump();
2954    let result = recv_with_cancel(
2955        &preparer.prepared_rx,
2956        options.cancel.as_ref(),
2957        "CPU repair preparation worker stopped unexpectedly",
2958    );
2959    CpuControllerTimings::record(&timings.read_prepare_ns, started.elapsed());
2960    result.map(|prepared| prepared.set)
2961}
2962
2963/// Start one controller staging area. Inputs are admitted individually by
2964/// [`queue_live_stream_input`] rather than filling a group before the
2965/// lifecycle sees it.
2966fn begin_live_stream_batch(
2967    preparer: &CpuInputPreparer,
2968    mut set: StreamBatchSet,
2969    input_start: usize,
2970    aligned_len: usize,
2971    chunk_len: usize,
2972    layout: Arc<ControllerLayout>,
2973    trace: &ControllerExecutionTrace,
2974) -> Result<()> {
2975    set.start = input_start;
2976    set.len = 0;
2977    set.packed_stride = chunk_len;
2978    set.coefficients.fill(0);
2979    preparer
2980        .send_command(PreparationMessage::Begin(PrepareBatch {
2981            set,
2982            aligned_len,
2983            chunk_len,
2984            layout: Some(layout),
2985        }))
2986        .map_err(|_| {
2987            trace.record(ControllerExecutionEvent::Failed {
2988                phase: ControllerFailurePhase::Prepare,
2989            });
2990            Par2Error::ReedSolomonError {
2991                reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
2992            }
2993        })
2994}
2995
2996/// Fill one transfer buffer before waiting for the destination staging area.
2997/// This keeps disk work overlapped with the older active batch.
2998#[allow(clippy::too_many_arguments)]
2999fn read_live_stream_input(
3000    preparer: &mut CpuInputPreparer,
3001    plan: &RepairPlan,
3002    par2_set: &Par2FileSet,
3003    file_access: &mut dyn FileAccess,
3004    recovery_files: &mut HashMap<PathBuf, File>,
3005    source_reader: &mut Option<StreamSourceReader>,
3006    available_inputs: usize,
3007    source_index: usize,
3008    mut buffer: TransferBuffer,
3009    staging_area: usize,
3010    byte_start: usize,
3011    byte_len: usize,
3012    aligned_len: usize,
3013    options: &RepairOptions,
3014    trace: &ControllerExecutionTrace,
3015) -> Result<TransferBuffer> {
3016    check_cancel(options)?;
3017    if let Err(error) = read_stream_source_chunk(
3018        plan,
3019        par2_set,
3020        file_access,
3021        recovery_files,
3022        source_reader,
3023        available_inputs,
3024        source_index,
3025        byte_start,
3026        &mut buffer.bytes[..byte_len],
3027    ) {
3028        trace.record(ControllerExecutionEvent::Failed {
3029            phase: ControllerFailurePhase::Read,
3030        });
3031        preparer.return_transfer_buffer(buffer)?;
3032        return Err(error);
3033    }
3034    buffer.bytes[byte_len..aligned_len].fill(0);
3035    trace.record(ControllerExecutionEvent::SourceRead {
3036        source_index,
3037        staging_area,
3038    });
3039    Ok(buffer)
3040}
3041
3042/// Admit and dispatch a source whose transfer-buffer read has already
3043/// completed. The caller has cleared any controller backpressure first.
3044fn queue_live_stream_input(
3045    preparer: &mut CpuInputPreparer,
3046    lifecycle: &mut ControllerLifecycle,
3047    plan: &RepairPlan,
3048    source_index: usize,
3049    buffer: TransferBuffer,
3050    trace: &ControllerExecutionTrace,
3051) -> Result<(usize, Option<InputBatch>)> {
3052    let expected_area = lifecycle.current_staging_area;
3053    let ControllerAddResult::Accepted {
3054        staging_area,
3055        slot,
3056        submitted,
3057    } = lifecycle.add_input(false)
3058    else {
3059        preparer.return_transfer_buffer(buffer)?;
3060        return Err(Par2Error::ReedSolomonError {
3061            reason: "CPU repair controller accepted a source while its staging area was full"
3062                .to_string(),
3063        });
3064    };
3065    if staging_area != expected_area {
3066        preparer.return_transfer_buffer(buffer)?;
3067        return Err(Par2Error::ReedSolomonError {
3068            reason: format!(
3069                "CPU repair controller changed staging area while admitting source {source_index}"
3070            ),
3071        });
3072    }
3073    let coefficients = (0..plan.input_factors.rows)
3074        .map(|output| plan.input_factors.get(output, source_index))
3075        .collect();
3076    preparer
3077        .send_command(PreparationMessage::Input {
3078            lane: slot,
3079            coefficients,
3080            buffer,
3081            submitted,
3082        })
3083        .map_err(|_| {
3084            trace.record(ControllerExecutionEvent::Failed {
3085                phase: ControllerFailurePhase::Prepare,
3086            });
3087            Par2Error::ReedSolomonError {
3088                reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
3089            }
3090        })?;
3091    trace.record(ControllerExecutionEvent::InputQueued {
3092        source_index,
3093        staging_area,
3094        slot,
3095    });
3096    Ok((staging_area, submitted))
3097}
3098
3099fn flush_live_stream_batch(
3100    preparer: &CpuInputPreparer,
3101    batch: InputBatch,
3102    trace: &ControllerExecutionTrace,
3103) -> Result<()> {
3104    preparer
3105        .send_command(PreparationMessage::Flush { batch })
3106        .map_err(|_| {
3107            trace.record(ControllerExecutionEvent::Failed {
3108                phase: ControllerFailurePhase::Prepare,
3109            });
3110            Par2Error::ReedSolomonError {
3111                reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
3112            }
3113        })
3114}
3115
3116enum FoldedBatchCoefficients<'a> {
3117    None,
3118    Gfni(Vec<[&'a crate::gf_simd::AffineMulMatrices; crate::gf_simd::FOLDED_GROUP]>),
3119    Shuffle2x(Vec<[&'a crate::gf_simd::Shuffle2xTables; crate::gf_simd::FOLDED_GROUP]>),
3120}
3121
3122impl<'a> FoldedBatchCoefficients<'a> {
3123    fn prepare(set: &StreamBatchSet, memo: &'a PreparedFactorMemo, output_count: usize) -> Self {
3124        if set.staging.is_empty() {
3125            return Self::None;
3126        }
3127        let groups = set.len.div_ceil(crate::gf_simd::FOLDED_GROUP);
3128        if crate::gf_simd::folded_uses_gfni() {
3129            let mut matrices = Vec::with_capacity(output_count * groups);
3130            for output in 0..output_count {
3131                for group in 0..groups {
3132                    matrices.push(std::array::from_fn(|lane| {
3133                        let input = group * crate::gf_simd::FOLDED_GROUP + lane;
3134                        memo.get_affine(if input < set.len {
3135                            set.coefficient(output, input)
3136                        } else {
3137                            0
3138                        })
3139                    }));
3140                }
3141            }
3142            Self::Gfni(matrices)
3143        } else {
3144            let mut tables = Vec::with_capacity(output_count * groups);
3145            for output in 0..output_count {
3146                for group in 0..groups {
3147                    tables.push(std::array::from_fn(|lane| {
3148                        let input = group * crate::gf_simd::FOLDED_GROUP + lane;
3149                        memo.get_shuffle2x(if input < set.len {
3150                            set.coefficient(output, input)
3151                        } else {
3152                            0
3153                        })
3154                    }));
3155                }
3156            }
3157            Self::Shuffle2x(tables)
3158        }
3159    }
3160}
3161
3162struct CpuComputeContext<'a> {
3163    output_base: usize,
3164    output_count: usize,
3165    set: StreamBatchSet,
3166    memo: &'a PreparedFactorMemo,
3167    #[cfg(target_arch = "x86_64")]
3168    jit_memo: Option<&'a JitMemo>,
3169    #[cfg(target_arch = "x86_64")]
3170    jit_batch: Option<reedsolomon_rs::xor_jit::packed::PackedJitBatch>,
3171    layout: Arc<ControllerLayout>,
3172    #[cfg_attr(not(target_arch = "x86_64"), allow(dead_code))]
3173    method: CpuMethodContract,
3174    trace: ControllerExecutionTrace,
3175    folded_coefficients: FoldedBatchCoefficients<'a>,
3176    add: bool,
3177}
3178
3179#[inline]
3180unsafe fn interleaved_output_ptr(
3181    base: usize,
3182    output_count: usize,
3183    output: usize,
3184    chunk_start: usize,
3185    chunk_len: usize,
3186) -> *mut u8 {
3187    unsafe { (base as *mut u8).add(chunk_start * output_count + output * chunk_len) }
3188}
3189
3190#[derive(Clone, Copy)]
3191struct CpuPlainSource {
3192    factor: u16,
3193    src: *const u8,
3194    len: usize,
3195}
3196
3197#[derive(Clone, Copy)]
3198struct CpuFoldedStaging {
3199    src: *const u8,
3200    len: usize,
3201}
3202
3203#[derive(Default)]
3204struct CpuWorkerScratch {
3205    active_inputs: Vec<usize>,
3206    plain_sources: Vec<CpuPlainSource>,
3207    folded_stagings: Vec<CpuFoldedStaging>,
3208    #[cfg(target_arch = "x86_64")]
3209    packed_scratch: reedsolomon_rs::xor_jit::packed::PackedScratch,
3210}
3211
3212#[inline]
3213fn xor_into(dst: &mut [u8], source: &[u8]) {
3214    for (out, input) in dst.iter_mut().zip(source.iter().copied()) {
3215        *out ^= input;
3216    }
3217}
3218
3219#[inline]
3220fn xor_folded_group_into(dst: &mut [u8], staging: &[u8], active_lanes: usize) {
3221    debug_assert_eq!(staging.len(), dst.len() * crate::gf_simd::FOLDED_GROUP);
3222    debug_assert!(active_lanes <= crate::gf_simd::FOLDED_GROUP);
3223    for (block, dst_block) in dst
3224        .chunks_exact_mut(crate::gf_simd::SPLIT_BLOCK_BYTES)
3225        .enumerate()
3226    {
3227        let staging_block =
3228            block * crate::gf_simd::FOLDED_GROUP * crate::gf_simd::SPLIT_BLOCK_BYTES;
3229        for lane in 0..active_lanes {
3230            let start = staging_block + lane * crate::gf_simd::SPLIT_BLOCK_BYTES;
3231            xor_into(
3232                dst_block,
3233                &staging[start..start + crate::gf_simd::SPLIT_BLOCK_BYTES],
3234            );
3235        }
3236    }
3237}
3238
3239fn run_cpu_worker(
3240    worker: usize,
3241    context: &CpuComputeContext<'_>,
3242    scratch: &mut CpuWorkerScratch,
3243) -> std::result::Result<(), String> {
3244    scratch.plain_sources.clear();
3245    scratch.folded_stagings.clear();
3246    #[cfg(target_arch = "x86_64")]
3247    if let Some(jit_memo) = context.jit_memo {
3248        let width = jit_memo.width;
3249        let jit_batch = context
3250            .jit_batch
3251            .as_ref()
3252            .expect("XOR-JIT compute context owns an active coefficient batch");
3253        let packed = staging_bytes(&context.set.packed).as_ptr();
3254        debug_assert_eq!(context.layout.aligned_len % width.block_bytes(), 0);
3255        debug_assert_eq!(context.layout.chunk_len, context.set.packed_stride);
3256        let packed_regions = context.set.input_grouping;
3257        let packed_chunk_bytes = packed_regions * context.set.packed_stride;
3258        for work in context
3259            .layout
3260            .assignments
3261            .iter()
3262            .filter(|work| work.worker == worker)
3263        {
3264            let work_end = work.byte_start + work.byte_len;
3265            let mut byte_start = work.byte_start;
3266            while byte_start < work_end {
3267                let chunk_index = byte_start / context.layout.chunk_len;
3268                let chunk_len = (work_end - byte_start).min(context.layout.chunk_len);
3269                let packed_chunk = unsafe { packed.add(chunk_index * packed_chunk_bytes) };
3270                let local_output_count = work.output_len;
3271                let prefetch = context.method.prefetch;
3272                let ideal_input_multiple = context.method.ideal_input_multiple.max(1);
3273                let pf_factor = prefetch.input_distance_shift;
3274                let mut inputs_prefetched_per_invoke = (context.set.len / ideal_input_multiple)
3275                    .saturating_mul(prefetch.inputs_per_invoke);
3276                let mut input_prefetch_out_offset = local_output_count.saturating_sub(1);
3277                if inputs_prefetched_per_invoke > 0
3278                    && inputs_prefetched_per_invoke > (1usize << pf_factor)
3279                {
3280                    inputs_prefetched_per_invoke -= 1usize << pf_factor;
3281                    inputs_prefetched_per_invoke <<= 3 - pf_factor;
3282                    let input_prefetch_passes =
3283                        (context.set.len << 3).div_ceil(inputs_prefetched_per_invoke);
3284                    input_prefetch_out_offset =
3285                        local_output_count.saturating_sub(input_prefetch_passes);
3286                }
3287                // Prefetch input only between rounds owned by this worker
3288                // request, never across an assignment boundary.
3289                let next_packed_chunk = (byte_start + chunk_len < work_end)
3290                    .then(|| unsafe { packed.add((chunk_index + 1) * packed_chunk_bytes) });
3291                for (local_output, output) in
3292                    (work.output_start..work.output_start + work.output_len).enumerate()
3293                {
3294                    let dst = unsafe {
3295                        interleaved_output_ptr(
3296                            context.output_base,
3297                            context.output_count,
3298                            output,
3299                            byte_start,
3300                            chunk_len,
3301                        )
3302                    };
3303                    if !context.add {
3304                        unsafe { std::slice::from_raw_parts_mut(dst, chunk_len) }.fill(0);
3305                    }
3306                    let prefetch_in = if local_output >= input_prefetch_out_offset {
3307                        next_packed_chunk.map(|next| unsafe {
3308                            next.add(
3309                                (inputs_prefetched_per_invoke
3310                                    .saturating_mul(local_output - input_prefetch_out_offset)
3311                                    .saturating_mul(chunk_len))
3312                                    >> 3,
3313                            )
3314                        })
3315                    } else {
3316                        None
3317                    };
3318                    let prefetch_out = (prefetch.output && local_output + 1 < work.output_len)
3319                        .then(|| unsafe { dst.add(chunk_len) as *const u8 });
3320                    unsafe {
3321                        jit_memo
3322                            .get(jit_batch, output)
3323                            .try_run_with_scratch(
3324                            &mut scratch.packed_scratch,
3325                            reedsolomon_rs::xor_jit::packed::PackedRun {
3326                                packed_regions,
3327                                live_regions: context.set.len,
3328                                dst,
3329                                src: packed_chunk,
3330                                len: chunk_len,
3331                                prefetch_in,
3332                                prefetch_out,
3333                            },
3334                        )
3335                        .map_err(|error| {
3336                            format!(
3337                                "XOR-JIT packed dispatch failed in worker {worker}, output {output}: {error}"
3338                            )
3339                        })?;
3340                    }
3341                }
3342                byte_start += chunk_len;
3343            }
3344        }
3345        return Ok(());
3346    }
3347
3348    if !matches!(&context.folded_coefficients, FoldedBatchCoefficients::None) {
3349        let groups = context.set.len.div_ceil(crate::gf_simd::FOLDED_GROUP);
3350        for work in context
3351            .layout
3352            .assignments
3353            .iter()
3354            .filter(|work| work.worker == worker)
3355        {
3356            let work_end = work.byte_start + work.byte_len;
3357            let mut byte_start = work.byte_start;
3358            while byte_start < work_end {
3359                let chunk_len = (work_end - byte_start).min(context.layout.chunk_len);
3360                let byte_end = byte_start + chunk_len;
3361                scratch.folded_stagings.clear();
3362                for group in 0..groups {
3363                    let staging = &staging_bytes(&context.set.staging[group])[byte_start
3364                        * crate::gf_simd::FOLDED_GROUP
3365                        ..byte_end * crate::gf_simd::FOLDED_GROUP];
3366                    scratch.folded_stagings.push(CpuFoldedStaging {
3367                        src: staging.as_ptr(),
3368                        len: staging.len(),
3369                    });
3370                }
3371                debug_assert!(groups <= 2);
3372                let mut staging_views: [&[u8]; 2] = [&[]; 2];
3373                for (group, source) in scratch.folded_stagings.iter().enumerate() {
3374                    staging_views[group] =
3375                        unsafe { std::slice::from_raw_parts(source.src, source.len) };
3376                }
3377                for output in work.output_start..work.output_start + work.output_len {
3378                    let dst = unsafe {
3379                        std::slice::from_raw_parts_mut(
3380                            interleaved_output_ptr(
3381                                context.output_base,
3382                                context.output_count,
3383                                output,
3384                                byte_start,
3385                                chunk_len,
3386                            ),
3387                            chunk_len,
3388                        )
3389                    };
3390                    if !context.add {
3391                        dst.fill(0);
3392                    }
3393                    if (0..context.set.len).all(|input| context.set.coefficient(output, input) == 1)
3394                    {
3395                        for (group, source) in scratch.folded_stagings.iter().enumerate() {
3396                            let staging =
3397                                unsafe { std::slice::from_raw_parts(source.src, source.len) };
3398                            let active_lanes = context
3399                                .set
3400                                .len
3401                                .saturating_sub(group * crate::gf_simd::FOLDED_GROUP)
3402                                .min(crate::gf_simd::FOLDED_GROUP);
3403                            xor_folded_group_into(dst, staging, active_lanes);
3404                        }
3405                    } else {
3406                        match &context.folded_coefficients {
3407                            FoldedBatchCoefficients::Gfni(matrices) => {
3408                                let matrix_start = output * groups;
3409                                crate::gf_simd::mul_acc_folded_batch(
3410                                    dst,
3411                                    &staging_views[..groups],
3412                                    &matrices[matrix_start..matrix_start + groups],
3413                                );
3414                            }
3415                            FoldedBatchCoefficients::Shuffle2x(tables) => {
3416                                let table_start = output * groups;
3417                                crate::gf_simd::mul_acc_shuffle2x_batch(
3418                                    dst,
3419                                    &staging_views[..groups],
3420                                    &tables[table_start..table_start + groups],
3421                                );
3422                            }
3423                            FoldedBatchCoefficients::None => unreachable!(),
3424                        }
3425                    }
3426                }
3427                byte_start = byte_end;
3428            }
3429        }
3430        scratch.folded_stagings.clear();
3431        return Ok(());
3432    }
3433
3434    for work in context
3435        .layout
3436        .assignments
3437        .iter()
3438        .filter(|work| work.worker == worker)
3439    {
3440        let work_end = work.byte_start + work.byte_len;
3441        let mut byte_start = work.byte_start;
3442        while byte_start < work_end {
3443            let chunk_len = (work_end - byte_start).min(context.layout.chunk_len);
3444            let byte_end = byte_start + chunk_len;
3445            for output in work.output_start..work.output_start + work.output_len {
3446                scratch.plain_sources.clear();
3447                scratch.active_inputs.clear();
3448                scratch.active_inputs.extend(
3449                    (0..context.set.len)
3450                        .filter(|input| context.set.coefficient(output, *input) != 0),
3451                );
3452                for &input in &scratch.active_inputs {
3453                    let factor = context.set.coefficient(output, input);
3454                    let source = &context.set.bufs[input][byte_start..byte_end];
3455                    scratch.plain_sources.push(CpuPlainSource {
3456                        factor,
3457                        src: source.as_ptr(),
3458                        len: source.len(),
3459                    });
3460                }
3461                let dst = unsafe {
3462                    std::slice::from_raw_parts_mut(
3463                        interleaved_output_ptr(
3464                            context.output_base,
3465                            context.output_count,
3466                            output,
3467                            byte_start,
3468                            chunk_len,
3469                        ),
3470                        chunk_len,
3471                    )
3472                };
3473                if !context.add {
3474                    dst.fill(0);
3475                }
3476                if !scratch.plain_sources.is_empty() {
3477                    let all_one = scratch
3478                        .active_inputs
3479                        .iter()
3480                        .all(|&input| context.set.coefficient(output, input) == 1);
3481                    if all_one {
3482                        for source in &scratch.plain_sources {
3483                            let source =
3484                                unsafe { std::slice::from_raw_parts(source.src, source.len) };
3485                            xor_into(dst, source);
3486                        }
3487                    } else {
3488                        debug_assert!(scratch.plain_sources.len() <= CPU_CONTROLLER_BUDGET_INPUTS);
3489                        let mut prepared: [MaybeUninit<crate::gf_simd::PreparedFactorSrc<'_>>;
3490                            CPU_CONTROLLER_BUDGET_INPUTS] =
3491                            std::array::from_fn(|_| MaybeUninit::uninit());
3492                        for (index, source) in scratch.plain_sources.iter().enumerate() {
3493                            let source_bytes =
3494                                unsafe { std::slice::from_raw_parts(source.src, source.len) };
3495                            prepared[index].write(crate::gf_simd::PreparedFactorSrc {
3496                                prepared: context.memo.get(source.factor),
3497                                src: source_bytes,
3498                            });
3499                        }
3500                        // SAFETY: every prefix element is initialized above, and
3501                        // the view lives only for this backend call.
3502                        let prepared = unsafe {
3503                            std::slice::from_raw_parts(
3504                                prepared
3505                                    .as_ptr()
3506                                    .cast::<crate::gf_simd::PreparedFactorSrc<'_>>(),
3507                                scratch.plain_sources.len(),
3508                            )
3509                        };
3510                        crate::gf_simd::mul_acc_input_batch_prepared(dst, prepared);
3511                    }
3512                }
3513            }
3514            byte_start = byte_end;
3515        }
3516    }
3517    scratch.plain_sources.clear();
3518    Ok(())
3519}
3520
3521struct CpuComputeJob<'a> {
3522    id: u64,
3523    context: Arc<CpuComputeContext<'a>>,
3524}
3525
3526struct CpuComputeCompletion {
3527    id: u64,
3528    worker: usize,
3529    elapsed: Duration,
3530    failure: Option<String>,
3531}
3532
3533struct CpuComputeTicket<'a> {
3534    id: u64,
3535    expected: usize,
3536    submission_failure: Option<String>,
3537    context: Arc<CpuComputeContext<'a>>,
3538}
3539
3540struct CpuComputeSubmitter<'a> {
3541    senders: Vec<std::sync::mpsc::SyncSender<CpuComputeJob<'a>>>,
3542    next_id: u64,
3543}
3544
3545struct CpuComputePool<'a> {
3546    completion_rx: std::sync::mpsc::Receiver<CpuComputeCompletion>,
3547    deferred: HashMap<u64, Vec<CpuComputeCompletion>>,
3548    _lifetime: std::marker::PhantomData<&'a ()>,
3549}
3550
3551impl<'a> CpuComputeSubmitter<'a> {
3552    fn submit(&mut self, context: CpuComputeContext<'a>) -> CpuComputeTicket<'a> {
3553        let id = self.next_id;
3554        self.next_id = self.next_id.wrapping_add(1);
3555        let context = Arc::new(context);
3556        let active_workers = context
3557            .layout
3558            .assignments
3559            .iter()
3560            .map(|work| work.worker)
3561            .max()
3562            .map_or(0, |worker| worker + 1);
3563        let mut expected = 0usize;
3564        let mut submission_failure = None;
3565        for sender in self.senders.iter().take(active_workers) {
3566            if sender
3567                .send(CpuComputeJob {
3568                    id,
3569                    context: Arc::clone(&context),
3570                })
3571                .is_err()
3572            {
3573                submission_failure =
3574                    Some("CPU repair compute worker stopped unexpectedly".to_string());
3575                break;
3576            }
3577            expected += 1;
3578        }
3579        CpuComputeTicket {
3580            id,
3581            expected,
3582            submission_failure,
3583            context,
3584        }
3585    }
3586}
3587
3588impl<'a> CpuComputePool<'a> {
3589    fn wait(
3590        &mut self,
3591        ticket: CpuComputeTicket<'a>,
3592        cancel: Option<&CancellationToken>,
3593        timings: &CpuControllerTimings,
3594    ) -> Result<CpuComputeContext<'a>> {
3595        let CpuComputeTicket {
3596            id,
3597            expected,
3598            submission_failure,
3599            context,
3600        } = ticket;
3601        let mut max_elapsed = Duration::ZERO;
3602        let mut failure = submission_failure;
3603        let mut cancelled = cancel.is_some_and(|token| token.is_cancelled());
3604        let mut completions = self.deferred.remove(&id).unwrap_or_default();
3605        while completions.len() < expected {
3606            let completion = loop {
3607                match self.completion_rx.recv_timeout(Duration::from_millis(20)) {
3608                    Ok(completion) => break completion,
3609                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
3610                        cancelled |= cancel.is_some_and(|token| token.is_cancelled());
3611                    }
3612                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
3613                        return Err(Par2Error::ReedSolomonError {
3614                            reason: "CPU repair compute workers stopped unexpectedly".to_string(),
3615                        });
3616                    }
3617                }
3618            };
3619            if completion.id == id {
3620                completions.push(completion);
3621            } else {
3622                self.deferred
3623                    .entry(completion.id)
3624                    .or_default()
3625                    .push(completion);
3626            }
3627        }
3628        for completion in completions {
3629            cancelled |= cancel.is_some_and(|token| token.is_cancelled());
3630            max_elapsed = max_elapsed.max(completion.elapsed);
3631            if let Some(reason) = completion.failure {
3632                failure.get_or_insert_with(|| {
3633                    format!("CPU repair worker {} failed: {reason}", completion.worker)
3634                });
3635            }
3636        }
3637        CpuControllerTimings::record(&timings.compute_ns, max_elapsed);
3638        if cancelled {
3639            Err(Par2Error::Cancelled)
3640        } else if let Some(reason) = failure {
3641            Err(Par2Error::ReedSolomonError { reason })
3642        } else {
3643            Arc::try_unwrap(context).map_err(|_| Par2Error::ReedSolomonError {
3644                reason: "CPU repair batch remained active after worker completion".to_string(),
3645            })
3646        }
3647    }
3648}
3649
3650#[allow(clippy::too_many_arguments)]
3651fn submit_prepared_controller_batch<'a>(
3652    batch: InputBatch,
3653    prepared: PrepareBatch,
3654    output_base: usize,
3655    output_count: usize,
3656    memo: &'a PreparedFactorMemo,
3657    #[cfg(target_arch = "x86_64")] jit_memo: Option<&'a JitMemo>,
3658    #[cfg(target_arch = "x86_64")] jit_workspaces: &mut [reedsolomon_rs::xor_jit::packed::PackedJitWorkspace;
3659             2],
3660    method: CpuMethodContract,
3661    #[cfg_attr(not(target_arch = "x86_64"), allow(unused_variables))]
3662    timings: &CpuControllerTimings,
3663    trace: &ControllerExecutionTrace,
3664    compute_submitter: &mut CpuComputeSubmitter<'a>,
3665) -> std::result::Result<SubmittedControllerBatch<'a>, String> {
3666    let staging_area = batch.staging_area;
3667    let layout = prepared
3668        .layout
3669        .ok_or_else(|| "CPU repair batch is missing its controller layout".to_string())?;
3670    let folded_coefficients = FoldedBatchCoefficients::prepare(&prepared.set, memo, output_count);
3671    #[cfg(target_arch = "x86_64")]
3672    let jit_batch = if let Some(jit_memo) = jit_memo {
3673        let started = Instant::now();
3674        let built = jit_memo.build_active_batch(&prepared.set, &mut jit_workspaces[staging_area]);
3675        CpuControllerTimings::record(&timings.jit_prepare_ns, started.elapsed());
3676        Some(built.map_err(|error| {
3677            trace.record(ControllerExecutionEvent::Failed {
3678                phase: ControllerFailurePhase::Compute,
3679            });
3680            format!("XOR-JIT packed batch generation failed: {error}")
3681        })?)
3682    } else {
3683        None
3684    };
3685    let compute_context = CpuComputeContext {
3686        output_base,
3687        output_count,
3688        set: prepared.set,
3689        memo,
3690        #[cfg(target_arch = "x86_64")]
3691        jit_memo,
3692        #[cfg(target_arch = "x86_64")]
3693        jit_batch,
3694        layout,
3695        method,
3696        trace: trace.clone(),
3697        folded_coefficients,
3698        add: batch.add,
3699    };
3700    let ticket = compute_submitter.submit(compute_context);
3701    trace.record(ControllerExecutionEvent::ComputeSubmitted {
3702        staging_area,
3703        add: batch.add,
3704    });
3705    Ok(SubmittedControllerBatch { batch, ticket })
3706}
3707
3708#[derive(Clone, Copy)]
3709enum SubmittedReceiveMode {
3710    ReadyOnly,
3711    Wait,
3712}
3713
3714fn receive_submitted_controller_batch<'a>(
3715    mode: SubmittedReceiveMode,
3716    preparer: &CpuInputPreparer<'a>,
3717    options: &RepairOptions,
3718    pending_prepared: &mut [Option<InputBatch>; 2],
3719    preparing: &mut [bool; 2],
3720    trace: &ControllerExecutionTrace,
3721    active: &mut [Option<CpuComputeTicket<'a>>; 2],
3722) -> Result<bool> {
3723    preparer.pump();
3724    let submitted = match mode {
3725        SubmittedReceiveMode::ReadyOnly => match preparer.submitted_rx.try_recv() {
3726            Ok(submitted) => submitted,
3727            Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(false),
3728            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
3729                trace.record(ControllerExecutionEvent::Failed {
3730                    phase: ControllerFailurePhase::Prepare,
3731                });
3732                return Err(Par2Error::ReedSolomonError {
3733                    reason: "CPU repair preparation worker stopped unexpectedly".to_string(),
3734                });
3735            }
3736        },
3737        SubmittedReceiveMode::Wait => recv_with_cancel(
3738            &preparer.submitted_rx,
3739            options.cancel.as_ref(),
3740            "CPU repair preparation worker stopped unexpectedly",
3741        )
3742        .inspect_err(|_| {
3743            trace.record(ControllerExecutionEvent::Failed {
3744                phase: ControllerFailurePhase::Prepare,
3745            });
3746        })?,
3747    }
3748    .map_err(|reason| Par2Error::ReedSolomonError { reason })?;
3749
3750    let staging_area = submitted.batch.staging_area;
3751    let expected = pending_prepared
3752        .get_mut(staging_area)
3753        .and_then(Option::take)
3754        .ok_or_else(|| Par2Error::ReedSolomonError {
3755            reason: format!(
3756                "CPU repair preparation completed unsubmitted staging area {staging_area}"
3757            ),
3758        })?;
3759    if submitted.batch != expected || !preparing[staging_area] {
3760        return Err(Par2Error::ReedSolomonError {
3761            reason: format!(
3762                "CPU repair preparation completed an unexpected batch for staging area {staging_area}"
3763            ),
3764        });
3765    }
3766    if active[staging_area].is_some() {
3767        return Err(Par2Error::ReedSolomonError {
3768            reason: format!("CPU repair controller submitted occupied staging area {staging_area}"),
3769        });
3770    }
3771    preparing[staging_area] = false;
3772    active[staging_area] = Some(submitted.ticket);
3773    Ok(true)
3774}
3775
3776#[allow(clippy::too_many_arguments)]
3777fn complete_active_controller_batch<'a>(
3778    staging_area: usize,
3779    preparer: &CpuInputPreparer<'a>,
3780    compute_pool: &mut CpuComputePool<'a>,
3781    active: &mut [Option<CpuComputeTicket<'a>>; 2],
3782    batch_sets: &mut [Option<StreamBatchSet>; 2],
3783    lifecycle: &mut ControllerLifecycle,
3784    options: &RepairOptions,
3785    timings: &CpuControllerTimings,
3786    trace: &ControllerExecutionTrace,
3787) -> Result<()> {
3788    // Inline execution runs the queued compute jobs here; the wait below then
3789    // finds their completions already delivered, exactly as it would with
3790    // worker threads that had finished first.
3791    preparer.pump();
3792    #[allow(unused_mut)]
3793    let mut finished = compute_pool.wait(
3794        active[staging_area]
3795            .take()
3796            .expect("active controller staging area has a ticket"),
3797        options.cancel.as_ref(),
3798        timings,
3799    )?;
3800    #[cfg(target_arch = "x86_64")]
3801    if let Some(jit_batch) = finished.jit_batch.take()
3802        && jit_batch.requires_workspace_recycle()
3803    {
3804        preparer
3805            .send_command(PreparationMessage::RecycleJit {
3806                staging_area,
3807                batch: jit_batch,
3808            })
3809            .map_err(|_| Par2Error::ReedSolomonError {
3810                reason: "CPU repair preparation worker stopped before recycling XOR-JIT state"
3811                    .to_string(),
3812            })?;
3813    }
3814    batch_sets[staging_area] = Some(finished.set);
3815    lifecycle.complete_batch(staging_area);
3816    trace.record(ControllerExecutionEvent::ComputeCompleted { staging_area });
3817    Ok(())
3818}
3819
3820/// One compute worker's loop *body*, factored out of [`run_compute_worker`] so
3821/// the identical body can also run inline on the controller thread where no
3822/// worker thread can be spawned, as on WebAssembly without thread support.
3823struct ComputeWorker {
3824    worker: usize,
3825    scratch: CpuWorkerScratch,
3826}
3827
3828impl ComputeWorker {
3829    fn new(worker: usize) -> Self {
3830        Self {
3831            worker,
3832            scratch: CpuWorkerScratch::default(),
3833        }
3834    }
3835
3836    /// Run one job and report its completion. `Break` reproduces the two `break`
3837    /// arms of the original loop: a closed completion channel, or a failed job.
3838    fn step(
3839        &mut self,
3840        job: CpuComputeJob<'_>,
3841        completion_tx: &std::sync::mpsc::SyncSender<CpuComputeCompletion>,
3842    ) -> ControlFlow<()> {
3843        let CpuComputeJob { id, context } = job;
3844        let started = Instant::now();
3845        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3846            run_cpu_worker(self.worker, &context, &mut self.scratch)
3847        }));
3848        let failure = match result {
3849            Ok(Ok(())) => None,
3850            Ok(Err(reason)) => Some(reason),
3851            Err(_) => Some("kernel panicked".to_string()),
3852        };
3853        if failure.is_some() {
3854            context.trace.record(ControllerExecutionEvent::Failed {
3855                phase: ControllerFailurePhase::Compute,
3856            });
3857        }
3858        self.scratch.plain_sources.clear();
3859        self.scratch.folded_stagings.clear();
3860        // The completion means this worker has released every reference to
3861        // the staging area, so the controller may safely reuse it.
3862        drop(context);
3863        if completion_tx
3864            .send(CpuComputeCompletion {
3865                id,
3866                worker: self.worker,
3867                elapsed: started.elapsed(),
3868                failure: failure.clone(),
3869            })
3870            .is_err()
3871        {
3872            return ControlFlow::Break(());
3873        }
3874        if failure.is_some() {
3875            return ControlFlow::Break(());
3876        }
3877        ControlFlow::Continue(())
3878    }
3879}
3880
3881fn run_compute_worker<'a>(
3882    worker: usize,
3883    receiver: std::sync::mpsc::Receiver<CpuComputeJob<'a>>,
3884    completion_tx: std::sync::mpsc::SyncSender<CpuComputeCompletion>,
3885) {
3886    let mut state = ComputeWorker::new(worker);
3887    while let Ok(job) = receiver.recv() {
3888        if state.step(job, &completion_tx).is_break() {
3889            break;
3890        }
3891    }
3892}
3893
3894fn xor_out_known_data(
3895    recovery_buffers: &mut [Vec<u8>],
3896    recovery_factors: &[u16],
3897    data: &[u8],
3898    chunk_words: usize,
3899) {
3900    assert_eq!(
3901        recovery_buffers.len(),
3902        recovery_factors.len(),
3903        "recovery factor count must match recovery buffer count"
3904    );
3905    assert!(
3906        data.len().is_multiple_of(2),
3907        "PAR2 slice_size must be a multiple of 2"
3908    );
3909
3910    let word_count = data.len() / 2;
3911    let chunk_words = chunk_words.max(1).min(word_count.max(1));
3912    let word_chunks: Vec<usize> = (0..word_count.max(1)).step_by(chunk_words).collect();
3913    let recovery_ptrs: Vec<usize> = recovery_buffers
3914        .iter_mut()
3915        .map(|recovery| recovery.as_mut_ptr() as usize)
3916        .collect();
3917
3918    word_chunks.par_iter().for_each(|&chunk_start| {
3919        let chunk_end = (chunk_start + chunk_words).min(word_count);
3920        let byte_start = chunk_start * 2;
3921        let byte_len = (chunk_end - chunk_start) * 2;
3922        let src = &data[byte_start..byte_start + byte_len];
3923
3924        for factor_start in (0..recovery_factors.len()).step_by(XOR_OUT_PAR_CHUNK) {
3925            let factor_end = (factor_start + XOR_OUT_PAR_CHUNK).min(recovery_factors.len());
3926            let mut pairs: Vec<crate::gf_simd::FactorDst<'_>> =
3927                Vec::with_capacity(factor_end - factor_start);
3928
3929            for idx in factor_start..factor_end {
3930                let factor = recovery_factors[idx];
3931                if factor == 0 {
3932                    continue;
3933                }
3934
3935                let dst = unsafe {
3936                    let ptr = recovery_ptrs[idx] as *mut u8;
3937                    std::slice::from_raw_parts_mut(ptr.add(byte_start), byte_len)
3938                };
3939                pairs.push(crate::gf_simd::FactorDst { factor, dst });
3940            }
3941
3942            if !pairs.is_empty() {
3943                crate::gf_simd::mul_acc_multi_region(&mut pairs, src);
3944            }
3945        }
3946    });
3947}
3948
3949fn read_exact_at_cached(
3950    files: &mut HashMap<PathBuf, File>,
3951    path: &Path,
3952    offset: u64,
3953    dst: &mut [u8],
3954) -> io::Result<()> {
3955    let file = if let Some(file) = files.get_mut(path) {
3956        file
3957    } else {
3958        files.insert(path.to_path_buf(), File::open(path)?);
3959        files.get_mut(path).expect("cached file should exist")
3960    };
3961    file.seek(SeekFrom::Start(offset))?;
3962    file.read_exact(dst)
3963}
3964
3965fn fill_recovery_chunk(
3966    data: &crate::packet::RecoverySliceData,
3967    start: usize,
3968    dst: &mut [u8],
3969    file_cache: &mut HashMap<PathBuf, File>,
3970) -> io::Result<()> {
3971    dst.fill(0);
3972
3973    if let Some(bytes) = data.as_bytes() {
3974        if start >= bytes.len() {
3975            return Ok(());
3976        }
3977        let end = (start + dst.len()).min(bytes.len());
3978        let copy_len = end - start;
3979        dst[..copy_len].copy_from_slice(&bytes[start..end]);
3980        return Ok(());
3981    }
3982
3983    let Some((path, base_offset, len)) = data.file_span() else {
3984        return Ok(());
3985    };
3986    if start >= len {
3987        return Ok(());
3988    }
3989
3990    let read_len = dst.len().min(len - start);
3991    read_exact_at_cached(
3992        file_cache,
3993        path,
3994        base_offset + start as u64,
3995        &mut dst[..read_len],
3996    )
3997}
3998
3999/// Execute a repair plan, reading recovery data and writing repaired slices.
4000///
4001/// The algorithm:
4002/// 1. For each recovery block, read its data
4003/// 2. XOR out contributions from all *known* input slices
4004/// 3. Multiply the adjusted recovery data by the decode matrix to get missing slices
4005/// 4. Write repaired slices back to files
4006pub fn execute_repair(
4007    plan: &RepairPlan,
4008    par2_set: &Par2FileSet,
4009    file_access: &mut dyn FileAccess,
4010) -> Result<()> {
4011    execute_repair_with_options(plan, par2_set, file_access, &RepairOptions::default())
4012}
4013
4014/// Load recovery block data into buffers, one per selected recovery exponent.
4015/// Each buffer is resized to `plan.slice_size`.
4016pub fn prepare_recovery_buffers(
4017    plan: &RepairPlan,
4018    par2_set: &Par2FileSet,
4019    options: &RepairOptions,
4020) -> Result<Vec<Vec<u8>>> {
4021    let n = plan.missing_slices.len();
4022    let slice_size = plan.slice_size as usize;
4023
4024    let mut recovery_data: Vec<Vec<u8>> = Vec::with_capacity(n);
4025    for (i, &exp) in plan.recovery_exponents.iter().enumerate() {
4026        if let Some(ref cancel) = options.cancel
4027            && cancel.is_cancelled()
4028        {
4029            return Err(Par2Error::Cancelled);
4030        }
4031        let rs = par2_set
4032            .recovery_slices
4033            .get(&exp)
4034            .ok_or_else(|| Par2Error::ReedSolomonError {
4035                reason: format!("recovery block with exponent {exp} not found"),
4036            })?;
4037        let mut data = rs.data.to_vec().map_err(Par2Error::Io)?;
4038        data.resize(slice_size, 0);
4039        recovery_data.push(data);
4040
4041        if let Some(ref progress) = options.progress {
4042            progress(ProgressUpdate {
4043                stage: ProgressStage::ReadingRecovery,
4044                current: i as u32 + 1,
4045                total: n as u32,
4046                bytes_processed: (i + 1) as u64 * slice_size as u64,
4047                total_bytes: None,
4048                phase: ProgressPhase::Whole,
4049            });
4050        }
4051    }
4052
4053    Ok(recovery_data)
4054}
4055
4056/// XOR-out a single known-good input slice's contribution from all recovery buffers.
4057///
4058/// `global_idx` is the slice's position in the global input ordering.
4059/// `input_data` is the slice data (will be zero-padded to `plan.slice_size` if shorter).
4060pub fn xor_out_slice(
4061    recovery_buffers: &mut [Vec<u8>],
4062    plan: &RepairPlan,
4063    global_idx: usize,
4064    input_data: &[u8],
4065) {
4066    let slice_size = plan.slice_size as usize;
4067    assert!(
4068        slice_size.is_multiple_of(2),
4069        "PAR2 slice_size must be a multiple of 2"
4070    );
4071
4072    // Pad input data to slice_size if needed.
4073    let padded;
4074    let data = if input_data.len() < slice_size {
4075        padded = {
4076            let mut v = input_data.to_vec();
4077            v.resize(slice_size, 0);
4078            v
4079        };
4080        &padded[..]
4081    } else {
4082        &input_data[..slice_size]
4083    };
4084
4085    let recovery_factors: Vec<u16> = plan
4086        .recovery_exponents
4087        .iter()
4088        .map(|&exp| gf::pow(plan.constants[global_idx], exp))
4089        .collect();
4090
4091    xor_out_known_data(
4092        recovery_buffers,
4093        &recovery_factors,
4094        &data[..slice_size],
4095        slice_size / 2,
4096    );
4097}
4098
4099/// This legacy whole-buffer entry point is retained as an API symbol, but
4100/// native repair is exclusively driven by the streamed CPU controller.
4101pub fn reconstruct_and_write(
4102    _plan: &RepairPlan,
4103    _par2_set: &Par2FileSet,
4104    _recovery_buffers: Vec<Vec<u8>>,
4105    _file_access: &mut dyn FileAccess,
4106    _chunk_words: usize,
4107    _options: &RepairOptions,
4108) -> Result<()> {
4109    Err(Par2Error::ReedSolomonError {
4110        reason:
4111            "legacy in-memory PAR2 reconstruction is quarantined; use execute_repair_with_options"
4112                .to_string(),
4113    })
4114}
4115
4116// Kept only for differential experimentation in unit builds. Production
4117// targets compile one authoritative CPU repair controller.
4118#[cfg(test)]
4119#[allow(dead_code)]
4120fn legacy_reconstruct_and_write(
4121    plan: &RepairPlan,
4122    par2_set: &Par2FileSet,
4123    recovery_buffers: Vec<Vec<u8>>,
4124    file_access: &mut dyn FileAccess,
4125    chunk_words: usize,
4126    options: &RepairOptions,
4127) -> Result<()> {
4128    let n = plan.missing_slices.len();
4129    if n == 0 {
4130        return Ok(());
4131    }
4132
4133    let slice_size = plan.slice_size as usize;
4134    assert!(
4135        slice_size.is_multiple_of(2),
4136        "PAR2 slice_size must be a multiple of 2"
4137    );
4138    let word_count = slice_size / 2;
4139
4140    // Step 2: Multiply adjusted recovery data by decode matrix.
4141    info!("reconstructing {} missing slices", n);
4142
4143    #[cfg(target_arch = "x86_64")]
4144    if is_x86_feature_detected!("gfni") && is_x86_feature_detected!("avx2") {
4145        return reconstruct_and_write_grouped_inputs(
4146            plan,
4147            par2_set,
4148            recovery_buffers,
4149            file_access,
4150            chunk_words,
4151            options,
4152        );
4153    }
4154
4155    let mut repaired_slices: Vec<Vec<u8>> = vec![vec![0u8; slice_size]; n];
4156
4157    let total_chunks_usize = word_count.div_ceil(chunk_words);
4158    let total_chunks = total_chunks_usize.min(u32::MAX as usize) as u32;
4159    let repair_total_bytes = (word_count as u64).saturating_mul(2);
4160    let write_total_bytes = (n as u64).saturating_mul(slice_size as u64);
4161    let operation_total_bytes = repair_total_bytes.saturating_add(write_total_bytes);
4162
4163    check_cancel(options)?;
4164
4165    let completed_chunks = AtomicU32::new(0);
4166    let repaired_ptrs: Vec<usize> = repaired_slices
4167        .iter_mut()
4168        .map(|slice| slice.as_mut_ptr() as usize)
4169        .collect();
4170
4171    (0..total_chunks_usize)
4172        .into_par_iter()
4173        .try_for_each(|chunk_idx| -> Result<()> {
4174            check_cancel(options)?;
4175
4176            let chunk_start = chunk_idx * chunk_words;
4177            let chunk_end = (chunk_start + chunk_words).min(word_count);
4178            let chunk_len = chunk_end - chunk_start;
4179            let byte_start = chunk_start * 2;
4180            let byte_len = chunk_len * 2;
4181
4182            // Transposed loop: iterate recovery buffers in the outer loop so each
4183            // buffer is loaded into cache once and reused for all N output slices.
4184            // Uses multi-region kernel to read src once per SIMD chunk across all
4185            // destinations. GF addition is commutative — accumulation order doesn't
4186            // matter.
4187            for (r, recovery) in recovery_buffers.iter().enumerate() {
4188                let src = &recovery[byte_start..byte_start + byte_len];
4189                let mut pairs: Vec<crate::gf_simd::FactorDst<'_>> = (0..n)
4190                    .filter_map(|j| {
4191                        let factor = plan.decode_matrix.get(j, r);
4192                        if factor != 0 {
4193                            // Safe because each chunk task writes a disjoint byte
4194                            // range within every repaired slice, and the slices
4195                            // themselves live in distinct Vec allocations.
4196                            let dst = unsafe {
4197                                let ptr = repaired_ptrs[j] as *mut u8;
4198                                std::slice::from_raw_parts_mut(ptr.add(byte_start), byte_len)
4199                            };
4200                            Some(crate::gf_simd::FactorDst { factor, dst })
4201                        } else {
4202                            None
4203                        }
4204                    })
4205                    .collect();
4206                if !pairs.is_empty() {
4207                    crate::gf_simd::mul_acc_multi_region(&mut pairs, src);
4208                }
4209            }
4210
4211            if let Some(ref progress) = options.progress {
4212                let current = completed_chunks.fetch_add(1, Ordering::Relaxed) + 1;
4213                progress(ProgressUpdate {
4214                    stage: ProgressStage::Repairing,
4215                    current,
4216                    total: total_chunks,
4217                    bytes_processed: (current as u64)
4218                        .saturating_mul(chunk_words as u64)
4219                        .saturating_mul(2)
4220                        .min(repair_total_bytes),
4221                    total_bytes: Some(operation_total_bytes),
4222                    phase: ProgressPhase::Whole,
4223                });
4224            }
4225
4226            Ok(())
4227        })?;
4228
4229    check_cancel(options)?;
4230
4231    // Step 3: Write repaired slices back to files.
4232    info!("writing repaired slices to files");
4233    let write_targets = build_write_targets(plan, par2_set)?;
4234
4235    for (j, target) in write_targets.iter().enumerate() {
4236        check_cancel(options)?;
4237
4238        let slice_end = target.offset + plan.slice_size;
4239        let write_len = if slice_end > target.file_end {
4240            (target.file_end - target.offset) as usize
4241        } else {
4242            slice_size
4243        };
4244
4245        file_access
4246            .write_file_range(
4247                &target.file_id,
4248                target.offset,
4249                &repaired_slices[j][..write_len],
4250            )
4251            .map_err(|e| Par2Error::RepairWriteFailed {
4252                filename: target.filename.clone(),
4253                offset: target.offset,
4254                source: e,
4255            })?;
4256
4257        debug!(
4258            "repaired slice {} of file {} ({write_len} bytes at offset {})",
4259            plan.missing_slices[j].1, target.filename, target.offset
4260        );
4261
4262        if let Some(ref progress) = options.progress {
4263            progress(ProgressUpdate {
4264                stage: ProgressStage::WritingRepaired,
4265                current: j as u32 + 1,
4266                total: n as u32,
4267                bytes_processed: repair_total_bytes
4268                    .saturating_add((j + 1) as u64 * slice_size as u64),
4269                total_bytes: Some(operation_total_bytes),
4270                phase: ProgressPhase::Whole,
4271            });
4272        }
4273    }
4274
4275    info!("repair complete: {} slices restored", n);
4276    Ok(())
4277}
4278
4279#[cfg(all(test, target_arch = "x86_64"))]
4280#[allow(dead_code)]
4281fn reconstruct_and_write_grouped_inputs(
4282    plan: &RepairPlan,
4283    par2_set: &Par2FileSet,
4284    recovery_buffers: Vec<Vec<u8>>,
4285    file_access: &mut dyn FileAccess,
4286    chunk_words: usize,
4287    options: &RepairOptions,
4288) -> Result<()> {
4289    let n = plan.missing_slices.len();
4290    if n == 0 {
4291        return Ok(());
4292    }
4293
4294    let slice_size = plan.slice_size as usize;
4295    assert!(
4296        slice_size.is_multiple_of(2),
4297        "PAR2 slice_size must be a multiple of 2"
4298    );
4299    let word_count = slice_size / 2;
4300    let total_chunks_usize = word_count.div_ceil(chunk_words);
4301    let output_inputs = grouped_input_factors(&plan.decode_matrix);
4302    // Deduplicate prepared multiply tables per distinct factor value; see the
4303    // in-memory repair path for rationale.
4304    let mut factor_slots: HashMap<u16, usize> = HashMap::new();
4305    let mut prepared_factors: Vec<crate::gf_simd::PreparedInputFactor> = Vec::new();
4306    let prepared_output_inputs: Vec<Vec<(u16, usize)>> = output_inputs
4307        .iter()
4308        .map(|inputs| {
4309            inputs
4310                .iter()
4311                .map(|factor_input| {
4312                    let slot = *factor_slots.entry(factor_input.factor).or_insert_with(|| {
4313                        prepared_factors
4314                            .push(crate::gf_simd::prepare_input_factor(factor_input.factor));
4315                        prepared_factors.len() - 1
4316                    });
4317                    (factor_input.input_idx, slot)
4318                })
4319                .collect()
4320        })
4321        .collect();
4322
4323    let mut repaired_slices: Vec<Vec<u8>> = vec![vec![0u8; slice_size]; n];
4324    let completed_outputs = AtomicU32::new(0);
4325    let repair_total_bytes = (n as u64).saturating_mul(slice_size as u64);
4326    let write_total_bytes = (n as u64).saturating_mul(slice_size as u64);
4327    let operation_total_bytes = repair_total_bytes.saturating_add(write_total_bytes);
4328
4329    repaired_slices.par_iter_mut().enumerate().try_for_each(
4330        |(output_idx, repaired)| -> Result<()> {
4331            check_cancel(options)?;
4332
4333            let decode_inputs = &prepared_output_inputs[output_idx];
4334            let mut chunk_inputs = Vec::with_capacity(decode_inputs.len());
4335
4336            for chunk_idx in 0..total_chunks_usize {
4337                let chunk_start = chunk_idx * chunk_words;
4338                let chunk_end = (chunk_start + chunk_words).min(word_count);
4339                let byte_start = chunk_start * 2;
4340                let byte_len = (chunk_end - chunk_start) * 2;
4341
4342                chunk_inputs.clear();
4343                for (input_idx, factor_slot) in decode_inputs {
4344                    chunk_inputs.push(crate::gf_simd::PreparedFactorSrc {
4345                        prepared: &prepared_factors[*factor_slot],
4346                        src: &recovery_buffers[*input_idx as usize]
4347                            [byte_start..byte_start + byte_len],
4348                    });
4349                }
4350
4351                crate::gf_simd::mul_acc_input_batch_prepared(
4352                    &mut repaired[byte_start..byte_start + byte_len],
4353                    &chunk_inputs,
4354                );
4355            }
4356
4357            if let Some(ref progress) = options.progress {
4358                let current = completed_outputs.fetch_add(1, Ordering::Relaxed) + 1;
4359                progress(ProgressUpdate {
4360                    stage: ProgressStage::Repairing,
4361                    current,
4362                    total: n as u32,
4363                    bytes_processed: current as u64 * slice_size as u64,
4364                    total_bytes: Some(operation_total_bytes),
4365                    phase: ProgressPhase::Whole,
4366                });
4367            }
4368
4369            Ok(())
4370        },
4371    )?;
4372
4373    check_cancel(options)?;
4374
4375    info!("writing repaired slices to files");
4376    let write_targets = build_write_targets(plan, par2_set)?;
4377
4378    for (j, target) in write_targets.iter().enumerate() {
4379        check_cancel(options)?;
4380
4381        let slice_end = target.offset + plan.slice_size;
4382        let write_len = if slice_end > target.file_end {
4383            (target.file_end - target.offset) as usize
4384        } else {
4385            slice_size
4386        };
4387
4388        file_access
4389            .write_file_range(
4390                &target.file_id,
4391                target.offset,
4392                &repaired_slices[j][..write_len],
4393            )
4394            .map_err(|e| Par2Error::RepairWriteFailed {
4395                filename: target.filename.clone(),
4396                offset: target.offset,
4397                source: e,
4398            })?;
4399
4400        if let Some(ref progress) = options.progress {
4401            progress(ProgressUpdate {
4402                stage: ProgressStage::WritingRepaired,
4403                current: j as u32 + 1,
4404                total: n as u32,
4405                bytes_processed: repair_total_bytes
4406                    .saturating_add((j + 1) as u64 * slice_size as u64),
4407                total_bytes: Some(operation_total_bytes),
4408                phase: ProgressPhase::Whole,
4409            });
4410        }
4411    }
4412
4413    info!("repair complete: {} slices restored", n);
4414    Ok(())
4415}
4416
4417/// One engaged GPU session. Metal (native, unified memory) has precedence on
4418/// Apple Silicon; the wgpu backend (Vulkan/DX12/Metal) covers the remaining
4419/// platforms. Both expose the identical begin/accumulate/finish protocol.
4420#[cfg(any(
4421    all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4422    feature = "wgpu"
4423))]
4424enum GpuSession {
4425    #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4426    Metal(reedsolomon_rs::metal_gf16::MetalGf16Session),
4427    #[cfg(feature = "wgpu")]
4428    Wgpu(reedsolomon_rs::wgpu_gf16::WgpuGf16Session),
4429}
4430
4431#[cfg(any(
4432    all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4433    feature = "wgpu"
4434))]
4435impl GpuSession {
4436    fn try_new(outputs: usize, max_byte_len: usize, effective_bytes: u64) -> Option<Self> {
4437        #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4438        if let Some(session) = reedsolomon_rs::metal_gf16::MetalGf16Session::try_new(
4439            outputs,
4440            max_byte_len,
4441            effective_bytes,
4442        ) {
4443            return Some(GpuSession::Metal(session));
4444        }
4445        #[cfg(feature = "wgpu")]
4446        if let Some(session) = reedsolomon_rs::wgpu_gf16::WgpuGf16Session::try_new(
4447            outputs,
4448            max_byte_len,
4449            effective_bytes,
4450        ) {
4451            return Some(GpuSession::Wgpu(session));
4452        }
4453        None
4454    }
4455
4456    fn begin_chunk(&mut self, byte_len: usize) -> std::result::Result<(), &'static str> {
4457        match self {
4458            #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4459            GpuSession::Metal(s) => s.begin_chunk(byte_len),
4460            #[cfg(feature = "wgpu")]
4461            GpuSession::Wgpu(s) => s.begin_chunk(byte_len),
4462        }
4463    }
4464
4465    fn accumulate(
4466        &mut self,
4467        srcs: &[&[u8]],
4468        factor: impl Fn(usize, usize) -> u16,
4469    ) -> std::result::Result<(), &'static str> {
4470        match self {
4471            #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4472            GpuSession::Metal(s) => s.accumulate(srcs, factor),
4473            #[cfg(feature = "wgpu")]
4474            GpuSession::Wgpu(s) => s.accumulate(srcs, factor),
4475        }
4476    }
4477
4478    fn finish_chunk(&mut self, rows: &mut [Vec<u8>]) -> std::result::Result<(), &'static str> {
4479        match self {
4480            #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4481            GpuSession::Metal(s) => s.finish_chunk(rows),
4482            #[cfg(feature = "wgpu")]
4483            GpuSession::Wgpu(s) => s.finish_chunk(rows),
4484        }
4485    }
4486
4487    fn device_name(&self) -> String {
4488        match self {
4489            #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4490            GpuSession::Metal(s) => s.device_name(),
4491            #[cfg(feature = "wgpu")]
4492            GpuSession::Wgpu(s) => s.device_name(),
4493        }
4494    }
4495
4496    fn backend_name(&self) -> &'static str {
4497        match self {
4498            #[cfg(all(feature = "metal", target_os = "macos", target_arch = "aarch64"))]
4499            GpuSession::Metal(_) => "metal",
4500            #[cfg(feature = "wgpu")]
4501            GpuSession::Wgpu(_) => "wgpu",
4502        }
4503    }
4504}
4505
4506/// Optional GPU arm of the streaming compute. All platform gating lives
4507/// here: without a GPU feature every method is a no-op and the CPU path
4508/// runs unchanged; otherwise a session engages only when a device is
4509/// present and the repair is large enough to amortize dispatch (see
4510/// `reedsolomon_rs::{metal_gf16, wgpu_gf16}`). Any GPU error
4511/// permanently disables the arm and the caller redoes the affected chunk
4512/// on the CPU.
4513struct GpuComputeArm {
4514    #[cfg(any(
4515        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4516        feature = "wgpu"
4517    ))]
4518    session: Option<GpuSession>,
4519}
4520
4521impl GpuComputeArm {
4522    fn is_engaged(&self) -> bool {
4523        #[cfg(any(
4524            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4525            feature = "wgpu"
4526        ))]
4527        return self.session.is_some();
4528
4529        #[cfg(not(any(
4530            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4531            feature = "wgpu"
4532        )))]
4533        false
4534    }
4535
4536    fn disable(&mut self) {
4537        #[cfg(any(
4538            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4539            feature = "wgpu"
4540        ))]
4541        {
4542            self.session = None;
4543        }
4544    }
4545
4546    #[cfg(any(
4547        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4548        feature = "wgpu"
4549    ))]
4550    fn engage(enabled: bool, outputs: usize, max_byte_len: usize, effective_bytes: u64) -> Self {
4551        // The GPU arm consumes the plain staging shape. GPU policy is separate
4552        // from CPU method selection: an available XOR-JIT method remains the
4553        // CPU fallback if this arm declines or fails.
4554        let session = enabled
4555            .then(|| GpuSession::try_new(outputs, max_byte_len, effective_bytes))
4556            .flatten();
4557        if let Some(session) = &session {
4558            info!(
4559                backend = session.backend_name(),
4560                device = %session.device_name(),
4561                outputs,
4562                "gpu gf16 tier engaged for streaming repair"
4563            );
4564        }
4565        // A headless box whose only Vulkan ICD is llvmpipe has an adapter and
4566        // still gets the CPU tier; say so rather than leaving the operator to
4567        // wonder. Cheap: this never probes an adapter that was not probed.
4568        #[cfg(feature = "wgpu")]
4569        if session.is_none() && reedsolomon_rs::wgpu_gf16::auto_refused_cpu_adapter() {
4570            debug!("wgpu adapter is a cpu rasterizer; keeping the cpu gf16 tier");
4571        }
4572        Self { session }
4573    }
4574
4575    #[cfg(not(any(
4576        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4577        feature = "wgpu"
4578    )))]
4579    fn engage(
4580        _enabled: bool,
4581        _outputs: usize,
4582        _max_byte_len: usize,
4583        _effective_bytes: u64,
4584    ) -> Self {
4585        Self {}
4586    }
4587
4588    /// True when the GPU owns this chunk's accumulation.
4589    fn begin_chunk(&mut self, _byte_len: usize) -> bool {
4590        #[cfg(any(
4591            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4592            feature = "wgpu"
4593        ))]
4594        {
4595            if let Some(session) = self.session.as_mut() {
4596                match session.begin_chunk(_byte_len) {
4597                    Ok(()) => return true,
4598                    Err(reason) => {
4599                        warn!(reason, "gpu gf16 begin_chunk failed; using CPU path");
4600                        self.session = None;
4601                    }
4602                }
4603            }
4604        }
4605        false
4606    }
4607
4608    /// Queue one source batch. `Err` means the GPU arm died mid-chunk and
4609    /// the caller must redo the chunk on the CPU.
4610    fn accumulate(
4611        &mut self,
4612        _set: &StreamBatchSet,
4613        _plan: &RepairPlan,
4614        _byte_len: usize,
4615    ) -> std::result::Result<(), ()> {
4616        #[cfg(any(
4617            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4618            feature = "wgpu"
4619        ))]
4620        {
4621            let Some(session) = self.session.as_mut() else {
4622                return Err(());
4623            };
4624            let srcs: Vec<&[u8]> = _set.bufs[.._set.len]
4625                .iter()
4626                .map(|buf| &buf[.._byte_len])
4627                .collect();
4628            let matrix = &_plan.input_factors;
4629            let start = _set.start;
4630            if let Err(reason) = session.accumulate(&srcs, |j, s| matrix.get(j, start + s)) {
4631                warn!(reason, "gpu gf16 accumulate failed; redoing chunk on CPU");
4632                self.session = None;
4633                return Err(());
4634            }
4635            return Ok(());
4636        }
4637        #[allow(unreachable_code)]
4638        Err(())
4639    }
4640
4641    /// Drain the chunk's dispatches into the output rows.
4642    fn finish_chunk(
4643        &mut self,
4644        _rows: &mut [Vec<u8>],
4645        _byte_len: usize,
4646    ) -> std::result::Result<(), ()> {
4647        #[cfg(any(
4648            all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4649            feature = "wgpu"
4650        ))]
4651        {
4652            let Some(session) = self.session.as_mut() else {
4653                return Err(());
4654            };
4655            if let Err(reason) = session.finish_chunk(_rows) {
4656                warn!(reason, "gpu gf16 finish_chunk failed; redoing chunk on CPU");
4657                self.session = None;
4658                return Err(());
4659            }
4660            return Ok(());
4661        }
4662        #[allow(unreachable_code)]
4663        Err(())
4664    }
4665}
4666
4667#[derive(Default)]
4668struct CpuControllerTimings {
4669    read_prepare_ns: AtomicU64,
4670    jit_prepare_ns: AtomicU64,
4671    compute_ns: AtomicU64,
4672    finish_ns: AtomicU64,
4673    write_ns: AtomicU64,
4674}
4675
4676impl CpuControllerTimings {
4677    fn record(counter: &AtomicU64, elapsed: Duration) {
4678        let nanos = elapsed.as_nanos().min(u64::MAX as u128) as u64;
4679        counter.fetch_add(nanos, Ordering::Relaxed);
4680    }
4681
4682    fn micros(counter: &AtomicU64) -> u64 {
4683        counter.load(Ordering::Relaxed) / 1_000
4684    }
4685
4686    fn duration_micros(elapsed: Duration) -> u64 {
4687        elapsed.as_micros().min(u64::MAX as u128) as u64
4688    }
4689}
4690
4691fn execute_repair_streaming(
4692    plan: &RepairPlan,
4693    par2_set: &Par2FileSet,
4694    file_access: &mut dyn FileAccess,
4695    options: &RepairOptions,
4696    budget: usize,
4697) -> Result<()> {
4698    execute_repair_streaming_with_trace(
4699        plan,
4700        par2_set,
4701        file_access,
4702        options,
4703        budget,
4704        ControllerExecutionTrace::default(),
4705    )
4706}
4707
4708fn execute_repair_streaming_with_trace(
4709    plan: &RepairPlan,
4710    par2_set: &Par2FileSet,
4711    file_access: &mut dyn FileAccess,
4712    options: &RepairOptions,
4713    budget: usize,
4714    trace: ControllerExecutionTrace,
4715) -> Result<()> {
4716    check_cancel(options)?;
4717    let controller_started = Instant::now();
4718    let n = plan.missing_slices.len();
4719    if n == 0 {
4720        return Ok(());
4721    }
4722
4723    let slice_size = plan.slice_size as usize;
4724    assert!(
4725        slice_size.is_multiple_of(2),
4726        "PAR2 slice_size must be a multiple of 2"
4727    );
4728    let word_count = slice_size / 2;
4729    let operation_total_bytes = (word_count as u64).saturating_mul(2);
4730    let write_targets = build_write_targets(plan, par2_set)?;
4731    let mut recovery_files: HashMap<PathBuf, File> = HashMap::new();
4732    let available_inputs = plan.available_input_global_indices.len();
4733    let total_sources = available_inputs + plan.recovery_exponents.len();
4734    // The folded path keeps every buffer in split byte-plane layout end to
4735    // end: sources are encoded and group-interleaved as they are read,
4736    // accumulation runs plane-wise with register-resident folded matrices,
4737    // and outputs decode once per chunk before writing.
4738    // Select the CPU method from capability alone. Slice size and GPU policy
4739    // influence controller allocation and whether the separate GPU arm runs;
4740    // neither may silently replace a supported CPU method.
4741    let effective_bytes = (n as u64)
4742        .saturating_mul(total_sources as u64)
4743        .saturating_mul(plan.slice_size);
4744    // WEAVER_GF16_WGPU=1 forces the wgpu GPU arm: the x86 CPU fast tiers are
4745    // disabled for the run so the batch sets take the plain shape the GPU arm
4746    // consumes (`bufs`), with the universal CPU tier as the fallback.
4747    #[cfg(feature = "wgpu")]
4748    let gpu_forced = reedsolomon_rs::wgpu_gf16::force_requested();
4749    #[cfg(not(feature = "wgpu"))]
4750    let gpu_forced = false;
4751    // A discrete GPU claims accumulation from the x86 fast tiers on its own
4752    // (see `discrete_auto_candidate` for the measurements and why integrated
4753    // GPUs don't). The probe runs only where its answer can change the shape —
4754    // a host that would otherwise take an AVX2 fast tier — so aarch64/macOS
4755    // never initialize the wgpu stack here and keep their Metal precedence.
4756    //
4757    // Decision table (adapter class × gate, when an x86 fast tier exists):
4758    //   WEAVER_GF16_WGPU=1                → GPU owns accumulation (any adapter)
4759    //   auto + DiscreteGpu + ≥ size gate  → GPU owns accumulation
4760    //   auto + Integrated/Virtual/Cpu/…   → fast tier keeps it
4761    //   WEAVER_GF16_WGPU=0                → fast tier keeps it
4762    // With no fast tier, `engage` below keeps its own (CPU-rasterizer-only)
4763    // refusal, unchanged.
4764    #[cfg(feature = "wgpu")]
4765    let gpu_discrete_auto = !gpu_forced
4766        && crate::gf_simd::altmap_supported()
4767        && reedsolomon_rs::wgpu_gf16::discrete_auto_candidate(effective_bytes);
4768    #[cfg(not(feature = "wgpu"))]
4769    let gpu_discrete_auto = false;
4770    let gpu_preferred = gpu_forced || gpu_discrete_auto;
4771    // Select AVX2 XOR-JIT only on tuned CPU families, and only when its
4772    // persistent reservation leaves the controller a data chunk at least as
4773    // large as the non-JIT method would get; other machines, and every shape
4774    // where the reservation would cost a pass over the sources, retain the
4775    // non-JIT controller. `RARPAR_PAR2_XORJIT=0` pins the non-JIT kernel.
4776    #[cfg(target_arch = "x86_64")]
4777    let jit_width = reedsolomon_rs::xor_jit::JitWidth::detect()
4778        .filter(|_| std::env::var_os("RARPAR_PAR2_XORJIT").is_none_or(|v| v != "0"));
4779    let workers = rayon::current_num_threads().max(1);
4780    // The non-JIT kernel this host would otherwise run, and the chunk the
4781    // XOR-JIT tier has to match to be selected.
4782    #[cfg(target_arch = "x86_64")]
4783    let baseline_method = if crate::gf_simd::altmap_supported() {
4784        CpuKernelKind::Folded.method()
4785    } else {
4786        CpuKernelKind::Plain.method()
4787    };
4788    // Shape the JIT controller from its selected method contract. The cached
4789    // strict-W^X capability was established by the Reed-Solomon supported()
4790    // gate; sealing each active batch remains fallible.
4791    #[cfg(target_arch = "x86_64")]
4792    let jit_setup_started = Instant::now();
4793    #[cfg(target_arch = "x86_64")]
4794    let jit_memo = match jit_width {
4795        Some(width) => XorJitSelection {
4796            width,
4797            jit_method: CpuKernelKind::XorJit(width).method(),
4798            baseline_method,
4799            output_count: n,
4800            word_count,
4801            workers,
4802            budget,
4803        }
4804        .select_memo(&plan.input_factors.data)?,
4805        None => None,
4806    };
4807    #[cfg(target_arch = "x86_64")]
4808    let jit_setup = jit_setup_started.elapsed();
4809    #[cfg(not(target_arch = "x86_64"))]
4810    let jit_setup = Duration::ZERO;
4811    #[cfg(target_arch = "x86_64")]
4812    let use_xorjit = jit_memo.is_some();
4813    #[cfg(not(target_arch = "x86_64"))]
4814    let use_xorjit = false;
4815    let use_folded = crate::gf_simd::altmap_supported() && !use_xorjit;
4816    let cpu_kernel = if use_folded {
4817        CpuKernelKind::Folded
4818    } else {
4819        CpuKernelKind::Plain
4820    };
4821    // aarch64 can never reach `Folded` (`altmap_supported()` is x86-only), and
4822    // `Plain`'s 2-byte contract is not what the oracle stages on ARM. Take the
4823    // oracle's own packed contract instead. NEON is baseline on aarch64, so
4824    // there is no feature to detect; `WEAVER_PAR2_NEON_PACKED=0` pins the old
4825    // 2-byte contract for A/B.
4826    #[cfg(target_arch = "aarch64")]
4827    let cpu_kernel = if neon_packed_enabled() {
4828        CpuKernelKind::NeonPacked
4829    } else {
4830        cpu_kernel
4831    };
4832    #[cfg(target_arch = "x86_64")]
4833    let cpu_kernel = jit_memo
4834        .as_ref()
4835        .map_or(cpu_kernel, |memo| CpuKernelKind::XorJit(memo.width));
4836    let method = cpu_kernel.method();
4837    let input_grouping = method.input_grouping();
4838    #[cfg(target_arch = "x86_64")]
4839    let persistent_jit_bytes = jit_memo.as_ref().map_or(0, JitMemo::reserved_bytes);
4840    #[cfg(not(target_arch = "x86_64"))]
4841    let persistent_jit_bytes = 0;
4842    let staging_width = method.staging_width();
4843    let (mut chunk_words, selected_budget, mut max_controller) = controller_execution_parameters(
4844        plan,
4845        options,
4846        method,
4847        staging_width,
4848        persistent_jit_bytes,
4849        workers,
4850    )?;
4851    debug_assert_eq!(selected_budget, budget);
4852    let max_byte_len = chunk_words * 2;
4853    // A GPU session owns output rows and consumes ordinary source rows. When
4854    // the selected CPU method uses packed or folded rows, reserve a second
4855    // pair of staging areas so a failed GPU chunk can fall back to that same
4856    // CPU method without rereading sources or escaping the repair limit.
4857    let mut gpu = GpuComputeArm::engage(
4858        gpu_preferred || (!use_folded && !use_xorjit),
4859        n,
4860        max_byte_len,
4861        effective_bytes,
4862    );
4863    let mut gpu_staging = gpu.is_engaged();
4864    if gpu_staging {
4865        let extra_gpu_output_bytes = n
4866            .checked_mul(max_controller.layout().aligned_len)
4867            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
4868                reason: "GPU output allocation overflowed".to_string(),
4869            })?;
4870        let extra_gpu_staging_bytes = if use_folded || use_xorjit {
4871            max_controller
4872                .buffer_accounting()
4873                .staging_area_bytes
4874                .checked_mul(2)
4875                .ok_or_else(|| Par2Error::ResourceLimitExceeded {
4876                    reason: "GPU fallback staging allocation overflowed".to_string(),
4877                })?
4878        } else {
4879            0
4880        };
4881        let gpu_state_bytes = extra_gpu_output_bytes
4882            .checked_add(extra_gpu_staging_bytes)
4883            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
4884                reason: "GPU state accounting overflowed".to_string(),
4885            })?;
4886        let persistent_with_gpu_state = persistent_jit_bytes
4887            .checked_add(gpu_state_bytes)
4888            .ok_or_else(|| Par2Error::ResourceLimitExceeded {
4889                reason: "GPU state accounting overflowed".to_string(),
4890            })?;
4891        match controller_execution_parameters(
4892            plan,
4893            options,
4894            method,
4895            staging_width,
4896            persistent_with_gpu_state,
4897            workers,
4898        ) {
4899            Ok((words, _, controller)) => {
4900                chunk_words = words;
4901                max_controller = controller;
4902            }
4903            Err(error) => {
4904                warn!(reason = %error, "GPU state exceeds the repair memory limit; keeping the CPU controller");
4905                gpu.disable();
4906                gpu_staging = false;
4907            }
4908        }
4909    }
4910    let total_chunks_usize = word_count.div_ceil(chunk_words);
4911    let total_chunks = total_chunks_usize.min(u32::MAX as usize) as u32;
4912    debug_assert_eq!(max_controller.input_grouping(), input_grouping);
4913    #[cfg(any(
4914        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4915        feature = "wgpu"
4916    ))]
4917    let max_aligned_len = max_controller.layout().aligned_len;
4918    let physical_row_len = max_controller.buffer_accounting().physical_row_len;
4919    let factor_setup_started = Instant::now();
4920    let memo = PreparedFactorMemo::from_matrix(&plan.input_factors, use_folded);
4921    let factor_setup = factor_setup_started.elapsed();
4922    let buffer_setup_started = Instant::now();
4923    let mut batch_sets = [
4924        Some(StreamBatchSet::new(
4925            physical_row_len,
4926            input_grouping,
4927            staging_width,
4928            n,
4929            use_folded,
4930            use_xorjit,
4931            gpu_staging,
4932        )),
4933        Some(StreamBatchSet::new(
4934            physical_row_len,
4935            input_grouping,
4936            staging_width,
4937            n,
4938            use_folded,
4939            use_xorjit,
4940            gpu_staging,
4941        )),
4942    ];
4943    let mut cpu_output_area = AlignedOutputArea::new(n, physical_row_len);
4944    let output_base = cpu_output_area.base();
4945    #[cfg(any(
4946        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4947        feature = "wgpu"
4948    ))]
4949    let mut gpu_chunk_output: Vec<Vec<u8>> = vec![vec![0u8; max_aligned_len]; n];
4950    #[cfg(not(any(
4951        all(feature = "metal", target_os = "macos", target_arch = "aarch64"),
4952        feature = "wgpu"
4953    )))]
4954    let mut gpu_chunk_output: Vec<Vec<u8>> = Vec::new();
4955    let gpu_output_ptrs: Vec<usize> = gpu_chunk_output
4956        .iter_mut()
4957        .map(|output| output.as_mut_ptr() as usize)
4958        .collect();
4959    let buffer_setup = buffer_setup_started.elapsed();
4960    let timings = CpuControllerTimings::default();
4961
4962    info!(
4963        missing_slices = n,
4964        chunk_bytes = chunk_words * 2,
4965        budget_bytes = budget,
4966        source_batch = input_grouping,
4967        workers,
4968        backend = ?cpu_kernel,
4969        "repairing with CPU-controller streamed path"
4970    );
4971
4972    let repair_result = std::thread::scope(|scope| -> Result<()> {
4973        let (command_tx, command_rx) = std::sync::mpsc::sync_channel(2);
4974        let (complete_tx, complete_rx) = std::sync::mpsc::sync_channel(2);
4975        let (prepared_tx, prepared_rx) = std::sync::mpsc::sync_channel(1);
4976        let (submitted_tx, submitted_rx) = std::sync::mpsc::sync_channel(1);
4977        let (finished_tx, finished_rx) = std::sync::mpsc::sync_channel(2);
4978        let (compute_completion_tx, compute_completion_rx) =
4979            std::sync::mpsc::sync_channel(workers.saturating_mul(2).max(1));
4980        let mut compute_senders = Vec::with_capacity(workers);
4981        let mut compute_workers = Vec::with_capacity(workers);
4982        // `parallel_enabled()` const-folds to `true` off wasm, so this decision
4983        // — and every statement guarded by it below — exists only on wasm. On
4984        // `wasm32-wasip1` it is `false` and the worker loops run inline on this
4985        // thread; on `wasm32-wasip1-threads` it is `true` and everything below
4986        // is the spawning path, unchanged.
4987        #[cfg(target_family = "wasm")]
4988        let inline_execution = !reedsolomon_rs::threading::parallel_enabled();
4989        #[cfg(target_family = "wasm")]
4990        let mut inline_compute = Vec::with_capacity(workers);
4991        for worker_index in 0..workers {
4992            let (sender, receiver) = std::sync::mpsc::sync_channel(1);
4993            let completion_tx = compute_completion_tx.clone();
4994            compute_senders.push(sender);
4995            #[cfg(target_family = "wasm")]
4996            if inline_execution {
4997                inline_compute.push(InlineComputeWorker::new(
4998                    worker_index,
4999                    receiver,
5000                    completion_tx,
5001                ));
5002                continue;
5003            }
5004            compute_workers.push(scope.spawn(move || {
5005                run_compute_worker(worker_index, receiver, completion_tx);
5006            }));
5007        }
5008        drop(compute_completion_tx);
5009        let compute_submitter = CpuComputeSubmitter {
5010            senders: compute_senders,
5011            next_id: 0,
5012        };
5013        let mut compute_pool = CpuComputePool {
5014            completion_rx: compute_completion_rx,
5015            deferred: HashMap::new(),
5016            _lifetime: std::marker::PhantomData,
5017        };
5018        let preparation_trace = trace.clone();
5019        let preparation_memo = &memo;
5020        let preparation_timings = &timings;
5021        #[cfg(target_arch = "x86_64")]
5022        let preparation_jit_memo = jit_memo.as_ref();
5023        #[cfg(not(target_family = "wasm"))]
5024        let preparation_worker = scope.spawn(move || {
5025            run_guarded_preparation_worker(
5026                command_rx,
5027                complete_tx,
5028                prepared_tx,
5029                submitted_tx,
5030                finished_tx,
5031                cpu_kernel,
5032                method,
5033                output_base,
5034                n,
5035                preparation_memo,
5036                #[cfg(target_arch = "x86_64")]
5037                preparation_jit_memo,
5038                preparation_timings,
5039                compute_submitter,
5040                preparation_trace,
5041            )
5042        });
5043        // Same worker, same arguments; the only difference is who runs its loop.
5044        #[cfg(target_family = "wasm")]
5045        let (preparation_worker, inline_workers) = if inline_execution {
5046            (
5047                None,
5048                Some(std::cell::RefCell::new(InlineControllerWorkers::new(
5049                    command_rx,
5050                    PreparationWorker::new(
5051                        complete_tx,
5052                        prepared_tx,
5053                        submitted_tx,
5054                        finished_tx,
5055                        cpu_kernel,
5056                        method,
5057                        output_base,
5058                        n,
5059                        preparation_memo,
5060                        preparation_timings,
5061                        compute_submitter,
5062                        preparation_trace,
5063                    ),
5064                    inline_compute,
5065                ))),
5066            )
5067        } else {
5068            (
5069                Some(scope.spawn(move || {
5070                    run_guarded_preparation_worker(
5071                        command_rx,
5072                        complete_tx,
5073                        prepared_tx,
5074                        submitted_tx,
5075                        finished_tx,
5076                        cpu_kernel,
5077                        method,
5078                        output_base,
5079                        n,
5080                        preparation_memo,
5081                        preparation_timings,
5082                        compute_submitter,
5083                        preparation_trace,
5084                    )
5085                })),
5086                None,
5087            )
5088        };
5089        let mut preparer = CpuInputPreparer {
5090            command_tx,
5091            complete_rx,
5092            prepared_rx,
5093            submitted_rx,
5094            finished_rx,
5095            transfer_buffers: std::array::from_fn(|slot| {
5096                Some(TransferBuffer {
5097                    slot,
5098                    bytes: vec![0u8; physical_row_len],
5099                })
5100            }),
5101            transfer_buffer_len: physical_row_len,
5102            #[cfg(target_family = "wasm")]
5103            inline: inline_workers,
5104        };
5105
5106        let repair_result = (|| -> Result<()> {
5107            let mut chunk_idx = 0usize;
5108            let mut source_reader = None;
5109            while chunk_idx < total_chunks_usize {
5110                check_cancel(options)?;
5111
5112                let chunk_start = chunk_idx * chunk_words;
5113                let chunk_end = (chunk_start + chunk_words).min(word_count);
5114                let chunk_len = chunk_end - chunk_start;
5115                let byte_start = chunk_start * 2;
5116                let byte_len = chunk_len * 2;
5117                let controller = cpu_controller_plan(byte_len, n, workers, method, staging_width);
5118                let controller_layout = Arc::new(controller.layout().clone());
5119                debug!(
5120                    chunk = chunk_idx,
5121                    aligned_bytes = controller.layout().aligned_len,
5122                    compute_chunk_bytes = controller.layout().chunk_len,
5123                    compute_chunks = controller.layout().num_chunks,
5124                    assignments = controller.layout().assignments.len(),
5125                    input_grouping = controller.input_grouping(),
5126                    "CPU repair controller plan"
5127                );
5128                let gpu_chunk = gpu.begin_chunk(byte_len);
5129                let mut gpu_failed = false;
5130                if gpu_chunk {
5131                    // GPU dispatch remains on its existing two-set pipeline.
5132                    // A GPU failure redoes this whole outer chunk on the CPU.
5133                    let mut current_area = 0usize;
5134                    let first_len = total_sources.min(controller.input_grouping());
5135                    let mut current = fill_gpu_stream_batch(
5136                        &mut preparer,
5137                        batch_sets[current_area]
5138                            .take()
5139                            .expect("controller staging area available"),
5140                        plan,
5141                        par2_set,
5142                        file_access,
5143                        &mut recovery_files,
5144                        &mut source_reader,
5145                        available_inputs,
5146                        0,
5147                        first_len,
5148                        byte_start,
5149                        byte_len,
5150                        controller.layout().aligned_len,
5151                        controller.layout().chunk_len,
5152                        controller.layout().num_chunks,
5153                        options,
5154                        &timings,
5155                    )?;
5156                    let mut spare = batch_sets[1 - current_area].take();
5157                    let mut batch_start = 0usize;
5158                    while batch_start < total_sources {
5159                        check_cancel(options)?;
5160                        if gpu.accumulate(&current, plan, byte_len).is_err() {
5161                            gpu_failed = true;
5162                            break;
5163                        }
5164                        batch_start += current.len;
5165                        if batch_start < total_sources {
5166                            let next_len =
5167                                (total_sources - batch_start).min(controller.input_grouping());
5168                            let next = fill_gpu_stream_batch(
5169                                &mut preparer,
5170                                spare.take().expect("controller staging area available"),
5171                                plan,
5172                                par2_set,
5173                                file_access,
5174                                &mut recovery_files,
5175                                &mut source_reader,
5176                                available_inputs,
5177                                batch_start,
5178                                next_len,
5179                                byte_start,
5180                                byte_len,
5181                                controller.layout().aligned_len,
5182                                controller.layout().chunk_len,
5183                                controller.layout().num_chunks,
5184                                options,
5185                                &timings,
5186                            )?;
5187                            spare = Some(current);
5188                            current = next;
5189                            current_area = 1 - current_area;
5190                        }
5191                    }
5192                    batch_sets[current_area] = Some(current);
5193                    batch_sets[1 - current_area] = spare;
5194                } else {
5195                    // A transfer buffer is filled while the older staging area
5196                    // is still active; controller backpressure gates admission,
5197                    // not disk I/O.
5198                    let mut lifecycle = ControllerLifecycle::new(controller.input_grouping())
5199                        .with_execution_trace(trace.clone());
5200                    let mut active: [Option<CpuComputeTicket<'_>>; 2] = [None, None];
5201                    let mut batch_order = VecDeque::<usize>::with_capacity(2);
5202                    let mut preparing = [false; 2];
5203                    let mut pending_prepared: [Option<InputBatch>; 2] = [None, None];
5204
5205                    let mut input_start = 0usize;
5206                    while input_start < total_sources {
5207                        check_cancel(options)?;
5208                        while receive_submitted_controller_batch(
5209                            SubmittedReceiveMode::ReadyOnly,
5210                            &preparer,
5211                            options,
5212                            &mut pending_prepared,
5213                            &mut preparing,
5214                            &trace,
5215                            &mut active,
5216                        )? {}
5217
5218                        let read_area = lifecycle.current_staging_area;
5219                        let buffer = preparer.take_transfer_buffer(options.cancel.as_ref())?;
5220                        // The preparation worker dispatches compute before it
5221                        // releases the transfer buffer. Accept that ticket before
5222                        // touching controller backpressure for the newly read input.
5223                        while receive_submitted_controller_batch(
5224                            SubmittedReceiveMode::ReadyOnly,
5225                            &preparer,
5226                            options,
5227                            &mut pending_prepared,
5228                            &mut preparing,
5229                            &trace,
5230                            &mut active,
5231                        )? {}
5232                        let buffer = read_live_stream_input(
5233                            &mut preparer,
5234                            plan,
5235                            par2_set,
5236                            file_access,
5237                            &mut recovery_files,
5238                            &mut source_reader,
5239                            available_inputs,
5240                            input_start,
5241                            buffer,
5242                            read_area,
5243                            byte_start,
5244                            byte_len,
5245                            controller.layout().aligned_len,
5246                            options,
5247                            &trace,
5248                        )?;
5249
5250                        if lifecycle.can_add() == ControllerAddStatus::Full {
5251                            lifecycle.observe_backpressure();
5252                            lifecycle.wait_for_add();
5253                            let expected_area = lifecycle.current_staging_area;
5254                            let completed_area = batch_order.front().copied().ok_or_else(|| {
5255                                Par2Error::ReedSolomonError {
5256                                    reason: "CPU repair controller reached backpressure without a submitted batch"
5257                                        .to_string(),
5258                                }
5259                            })?;
5260                            if completed_area != expected_area {
5261                                return Err(Par2Error::ReedSolomonError {
5262                                    reason: format!(
5263                                        "CPU repair staging order mismatch: expected area {expected_area}, completed area {completed_area}"
5264                                    ),
5265                                });
5266                            }
5267                            while active[completed_area].is_none() {
5268                                receive_submitted_controller_batch(
5269                                    SubmittedReceiveMode::Wait,
5270                                    &preparer,
5271                                    options,
5272                                    &mut pending_prepared,
5273                                    &mut preparing,
5274                                    &trace,
5275                                    &mut active,
5276                                )?;
5277                            }
5278                            batch_order.pop_front();
5279                            complete_active_controller_batch(
5280                                completed_area,
5281                                &preparer,
5282                                &mut compute_pool,
5283                                &mut active,
5284                                &mut batch_sets,
5285                                &mut lifecycle,
5286                                options,
5287                                &timings,
5288                                &trace,
5289                            )?;
5290                        }
5291
5292                        let staging_area = lifecycle.current_staging_area;
5293                        debug_assert_eq!(staging_area, read_area);
5294                        if !preparing[staging_area] {
5295                            begin_live_stream_batch(
5296                                &preparer,
5297                                batch_sets[staging_area]
5298                                    .take()
5299                                    .expect("controller staging area available"),
5300                                input_start,
5301                                controller.layout().aligned_len,
5302                                controller.layout().chunk_len,
5303                                Arc::clone(&controller_layout),
5304                                &trace,
5305                            )?;
5306                            preparing[staging_area] = true;
5307                        }
5308                        let (accepted_area, submitted) = queue_live_stream_input(
5309                            &mut preparer,
5310                            &mut lifecycle,
5311                            plan,
5312                            input_start,
5313                            buffer,
5314                            &trace,
5315                        )?;
5316                        debug_assert_eq!(accepted_area, staging_area);
5317                        input_start += 1;
5318                        if let Some(batch) = submitted {
5319                            if batch.staging_area != staging_area
5320                                || pending_prepared[staging_area].replace(batch).is_some()
5321                            {
5322                                return Err(Par2Error::ReedSolomonError {
5323                                    reason: format!(
5324                                        "CPU repair controller resubmitted staging area {staging_area} before completion"
5325                                    ),
5326                                });
5327                            }
5328                            batch_order.push_back(staging_area);
5329                        }
5330                    }
5331
5332                    if let Some(batch) = lifecycle.end_input() {
5333                        let staging_area = batch.staging_area;
5334                        flush_live_stream_batch(&preparer, batch, &trace)?;
5335                        if pending_prepared[staging_area].replace(batch).is_some() {
5336                            return Err(Par2Error::ReedSolomonError {
5337                                reason: format!(
5338                                    "CPU repair controller flushed occupied staging area {staging_area}"
5339                                ),
5340                            });
5341                        }
5342                        batch_order.push_back(staging_area);
5343                    }
5344
5345                    while let Some(staging_area) = batch_order.pop_front() {
5346                        while active[staging_area].is_none() {
5347                            receive_submitted_controller_batch(
5348                                SubmittedReceiveMode::Wait,
5349                                &preparer,
5350                                options,
5351                                &mut pending_prepared,
5352                                &mut preparing,
5353                                &trace,
5354                                &mut active,
5355                            )?;
5356                        }
5357                        complete_active_controller_batch(
5358                            staging_area,
5359                            &preparer,
5360                            &mut compute_pool,
5361                            &mut active,
5362                            &mut batch_sets,
5363                            &mut lifecycle,
5364                            options,
5365                            &timings,
5366                            &trace,
5367                        )?;
5368                    }
5369                    debug_assert!(preparing.iter().all(|active| !active));
5370                    debug_assert!(pending_prepared.iter().all(Option::is_none));
5371                    lifecycle.processing_finished();
5372                }
5373                if gpu_chunk
5374                    && !gpu_failed
5375                    && gpu.finish_chunk(&mut gpu_chunk_output, byte_len).is_err()
5376                {
5377                    gpu_failed = true;
5378                }
5379                if gpu_failed {
5380                    // The arm is disabled; redo this chunk from batch zero on the
5381                    // CPU path.
5382                    continue;
5383                }
5384
5385                finish_and_write_stream_outputs(
5386                    &mut preparer,
5387                    if gpu_chunk {
5388                        OutputTransferLayout::PlainContiguous(gpu_output_ptrs.as_slice())
5389                    } else {
5390                        OutputTransferLayout::CpuEncodedChunkInterleaved {
5391                            base: output_base,
5392                            output_count: n,
5393                            chunk_len: controller.layout().chunk_len,
5394                        }
5395                    },
5396                    controller.layout().aligned_len,
5397                    byte_start,
5398                    byte_len,
5399                    &write_targets,
5400                    file_access,
5401                    options,
5402                    &timings,
5403                    &trace,
5404                )?;
5405
5406                if let Some(ref progress) = options.progress {
5407                    progress(ProgressUpdate {
5408                        stage: ProgressStage::Repairing,
5409                        current: chunk_idx as u32 + 1,
5410                        total: total_chunks,
5411                        bytes_processed: ((chunk_idx + 1) as u64)
5412                            .saturating_mul(chunk_words as u64)
5413                            .saturating_mul(2)
5414                            .min(operation_total_bytes),
5415                        total_bytes: Some(operation_total_bytes),
5416                        phase: ProgressPhase::Whole,
5417                    });
5418                }
5419                chunk_idx += 1;
5420            }
5421            Ok(())
5422        })();
5423        // Read before the preparer — which owns the inline workers — is dropped.
5424        #[cfg(target_family = "wasm")]
5425        let inline_preparation_panicked = preparer.inline_preparation_panicked();
5426        drop(compute_pool);
5427        drop(preparer);
5428        let mut compute_panicked = false;
5429        for worker in compute_workers {
5430            compute_panicked |= worker.join().is_err();
5431        }
5432        if compute_panicked && repair_result.is_ok() {
5433            return Err(Par2Error::ReedSolomonError {
5434                reason: "CPU repair compute worker panicked".to_string(),
5435            });
5436        }
5437        #[cfg(not(target_family = "wasm"))]
5438        let preparation_panicked = preparation_worker.join().unwrap_or(true);
5439        // Inline compute panics need no equivalent: `ComputeWorker::step`
5440        // catches them itself and reports them as a job failure on both paths,
5441        // so the empty `compute_workers` join above is the whole answer.
5442        #[cfg(target_family = "wasm")]
5443        let preparation_panicked = preparation_worker
5444            .map_or(inline_preparation_panicked, |worker| {
5445                worker.join().unwrap_or(true)
5446            });
5447        if preparation_panicked && repair_result.is_ok() {
5448            return Err(Par2Error::ReedSolomonError {
5449                reason: "CPU repair preparation worker panicked".to_string(),
5450            });
5451        }
5452        repair_result
5453    });
5454    repair_result?;
5455
5456    info!(
5457        missing_slices = n,
5458        total_us = CpuControllerTimings::duration_micros(controller_started.elapsed()),
5459        jit_setup_us = CpuControllerTimings::duration_micros(jit_setup),
5460        jit_prepare_work_us = CpuControllerTimings::micros(&timings.jit_prepare_ns),
5461        factor_setup_us = CpuControllerTimings::duration_micros(factor_setup),
5462        buffer_setup_us = CpuControllerTimings::duration_micros(buffer_setup),
5463        read_prepare_work_us = CpuControllerTimings::micros(&timings.read_prepare_ns),
5464        compute_work_us = CpuControllerTimings::micros(&timings.compute_ns),
5465        finish_us = CpuControllerTimings::micros(&timings.finish_ns),
5466        write_us = CpuControllerTimings::micros(&timings.write_ns),
5467        "streaming repair complete"
5468    );
5469    Ok(())
5470}
5471
5472// ── Host-agnostic repair-solver seam (RFC 123 WP2.5) ───────────────────────
5473//
5474// The Reed-Solomon reconstruct (`out[j] = XOR over sources s of
5475// gf::mul(coeff[j][s], src[s])`) is the one step that cannot run under
5476// single-threaded `wasm32-wasip1`: weaver parallelises it with rayon, which
5477// cannot spawn a pool there, and the decode-matrix build wants a large native
5478// stack. This seam lets a host inject a non-rayon solver (e.g. one that
5479// marshals the solve to native host threads) WITHOUT par2-rs depending on
5480// any host ABI. `RepairProblem` is a plain description of the solve — the same
5481// fields the frozen "PAR2 v2" host descriptor carries — so a wasm consumer can
5482// marshal it, while the native default keeps the current rayon behaviour.
5483
5484/// A host-agnostic description of a Reed-Solomon repair reconstruct.
5485///
5486/// Sources are ordered available inputs first (in `available_indices` order),
5487/// then the selected recovery blocks (in `recovery_exponents` order) — the same
5488/// column ordering as [`RepairPlan::input_factors`]. Each source and output
5489/// region is `word_count * 2` bytes; output regions are pairwise disjoint. A
5490/// solver reconstructs every `outputs[j]` in place, in `missing_indices` order.
5491///
5492/// This carries no pre-built matrix and no weaver-specific types: it is exactly
5493/// the data a host needs to (re)build the repair coefficient matrix (via
5494/// [`reedsolomon_rs::matrix::build_repair_matrix`]) and run the GF matmul.
5495pub struct RepairProblem<'a> {
5496    /// Total input slices in the recovery set (indexes `constants`).
5497    pub total_inputs: usize,
5498    /// `u16` words per slice; each region is `word_count * 2` bytes.
5499    pub word_count: usize,
5500    /// Global indices of the missing slices (one per output row).
5501    pub missing_indices: &'a [usize],
5502    /// Global indices of the available input slices (source columns first).
5503    pub available_indices: &'a [usize],
5504    /// Recovery-block exponents used, one per missing slice (source columns last).
5505    pub recovery_exponents: &'a [u32],
5506    /// PAR2 input-slice constants for every input slice in the recovery set.
5507    pub constants: &'a [u16],
5508    /// Source byte-regions: available inputs then recovery blocks.
5509    pub sources: &'a [&'a [u8]],
5510    /// Output byte-regions for the reconstructed missing slices (disjoint).
5511    pub outputs: &'a mut [&'a mut [u8]],
5512}
5513
5514impl RepairProblem<'_> {
5515    /// Bytes per source/output region (`word_count * 2`).
5516    #[inline]
5517    pub fn slice_bytes(&self) -> usize {
5518        self.word_count * 2
5519    }
5520}
5521
5522/// Failure returned by a [`RepairSolver`].
5523#[derive(Debug, Clone, PartialEq, Eq)]
5524pub enum SolverError {
5525    /// The selected recovery exponents form a singular (non-invertible) matrix.
5526    Singular {
5527        /// Index into `recovery_exponents` of the unusable row, when known.
5528        bad_row: Option<usize>,
5529    },
5530    /// The problem's dimensions were inconsistent with the solver.
5531    Dimensions(String),
5532    /// The solve was cancelled cooperatively.
5533    Cancelled,
5534    /// An opaque host-side failure (e.g. a wasm host-fn error code).
5535    Host(String),
5536}
5537
5538impl std::fmt::Display for SolverError {
5539    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5540        match self {
5541            SolverError::Singular { bad_row: Some(row) } => {
5542                write!(f, "repair matrix is singular (recovery row {row})")
5543            }
5544            SolverError::Singular { bad_row: None } => write!(f, "repair matrix is singular"),
5545            SolverError::Dimensions(reason) => write!(f, "repair problem dimensions: {reason}"),
5546            SolverError::Cancelled => write!(f, "repair reconstruct cancelled"),
5547            SolverError::Host(reason) => write!(f, "repair solver host failure: {reason}"),
5548        }
5549    }
5550}
5551
5552impl std::error::Error for SolverError {}
5553
5554impl From<SolverError> for Par2Error {
5555    fn from(error: SolverError) -> Self {
5556        match error {
5557            SolverError::Cancelled => Par2Error::Cancelled,
5558            other => Par2Error::ReedSolomonError {
5559                reason: other.to_string(),
5560            },
5561        }
5562    }
5563}
5564
5565/// The injectable reconstruct step of a PAR2 repair.
5566///
5567/// par2-rs calls this once per repair, at whole-reconstruct granularity;
5568/// the hot per-element GF loop lives entirely inside an implementation, so the
5569/// native default ([`NativeRepairSolver`]) is monomorphised with no dynamic
5570/// dispatch on that loop. A host (e.g. a wasm plugin) implements this to run
5571/// the solve off the rayon path — for example by marshaling `problem` to a
5572/// native host-thread solver — without par2-rs knowing any host ABI.
5573pub trait RepairSolver {
5574    /// Reconstruct every `problem.outputs[j]` in place from `problem.sources`.
5575    fn reconstruct(&self, problem: &mut RepairProblem<'_>) -> std::result::Result<(), SolverError>;
5576}
5577
5578/// The native default solver: weaver's rayon GF(2^16) matmul over the plan's
5579/// pre-built `input_factors`. Byte-identical to the pre-seam in-memory
5580/// reconstruct, and the path exercised by the crate's own repair tests.
5581pub struct NativeRepairSolver<'a> {
5582    input_factors: &'a matrix::Matrix,
5583    chunk_words: usize,
5584    cancel: Option<CancellationToken>,
5585}
5586
5587impl<'a> NativeRepairSolver<'a> {
5588    /// Build a solver over the plan's repair coefficient matrix. `chunk_words`
5589    /// is the per-output word tiling used by the rayon kernel.
5590    pub fn new(input_factors: &'a matrix::Matrix, chunk_words: usize) -> Self {
5591        Self {
5592            input_factors,
5593            chunk_words,
5594            cancel: None,
5595        }
5596    }
5597
5598    /// Attach a cancellation token, checked once per output row.
5599    pub fn with_cancellation(mut self, cancel: Option<CancellationToken>) -> Self {
5600        self.cancel = cancel;
5601        self
5602    }
5603}
5604
5605impl RepairSolver for NativeRepairSolver<'_> {
5606    fn reconstruct(&self, problem: &mut RepairProblem<'_>) -> std::result::Result<(), SolverError> {
5607        let n = problem.outputs.len();
5608        if n == 0 {
5609            return Ok(());
5610        }
5611        if self.input_factors.rows != n {
5612            return Err(SolverError::Dimensions(format!(
5613                "input_factors has {} rows but {n} outputs",
5614                self.input_factors.rows
5615            )));
5616        }
5617        if self.input_factors.cols != problem.sources.len() {
5618            return Err(SolverError::Dimensions(format!(
5619                "input_factors has {} cols but {} sources",
5620                self.input_factors.cols,
5621                problem.sources.len()
5622            )));
5623        }
5624
5625        let word_count = problem.word_count;
5626        let chunk_words = self.chunk_words.max(1);
5627        let total_chunks_usize = word_count.div_ceil(chunk_words);
5628
5629        // Deduplicate prepared multiply tables per distinct factor value; a
5630        // dense decode matrix repeats factors across (output, input) pairs, and
5631        // preparing one table per pair would multiply memory by outputs*inputs.
5632        let output_inputs = grouped_input_factors(self.input_factors);
5633        let mut factor_slots: HashMap<u16, usize> = HashMap::new();
5634        let mut prepared_factors: Vec<crate::gf_simd::PreparedInputFactor> = Vec::new();
5635        let prepared_output_inputs: Vec<Vec<(u16, usize)>> = output_inputs
5636            .iter()
5637            .map(|inputs| {
5638                inputs
5639                    .iter()
5640                    .map(|factor_input| {
5641                        let slot = *factor_slots.entry(factor_input.factor).or_insert_with(|| {
5642                            prepared_factors
5643                                .push(crate::gf_simd::prepare_input_factor(factor_input.factor));
5644                            prepared_factors.len() - 1
5645                        });
5646                        (factor_input.input_idx, slot)
5647                    })
5648                    .collect()
5649            })
5650            .collect();
5651
5652        let sources = problem.sources;
5653        let prepared_factors = &prepared_factors;
5654        let prepared_output_inputs = &prepared_output_inputs;
5655        let cancel = self.cancel.as_ref();
5656        let outputs = &mut *problem.outputs;
5657
5658        outputs.par_iter_mut().enumerate().try_for_each(
5659            |(output_idx, out)| -> std::result::Result<(), SolverError> {
5660                if let Some(cancel) = cancel
5661                    && cancel.is_cancelled()
5662                {
5663                    return Err(SolverError::Cancelled);
5664                }
5665                let out: &mut [u8] = out;
5666                let decode_inputs = &prepared_output_inputs[output_idx];
5667                let mut chunk_inputs = Vec::with_capacity(decode_inputs.len());
5668
5669                for chunk_idx in 0..total_chunks_usize {
5670                    let chunk_start = chunk_idx * chunk_words;
5671                    let chunk_end = (chunk_start + chunk_words).min(word_count);
5672                    let byte_start = chunk_start * 2;
5673                    let byte_len = (chunk_end - chunk_start) * 2;
5674
5675                    chunk_inputs.clear();
5676                    for (input_idx, factor_slot) in decode_inputs {
5677                        chunk_inputs.push(crate::gf_simd::PreparedFactorSrc {
5678                            prepared: &prepared_factors[*factor_slot],
5679                            src: &sources[*input_idx as usize][byte_start..byte_start + byte_len],
5680                        });
5681                    }
5682
5683                    crate::gf_simd::mul_acc_input_batch_prepared(
5684                        &mut out[byte_start..byte_start + byte_len],
5685                        &chunk_inputs,
5686                    );
5687                }
5688
5689                Ok(())
5690            },
5691        )
5692    }
5693}
5694
5695/// The wasm-only whole-buffer solver seam. Native targets compile and execute
5696/// only the streamed controller.
5697#[cfg(target_arch = "wasm32")]
5698fn run_in_memory_repair<S: RepairSolver + ?Sized>(
5699    plan: &RepairPlan,
5700    par2_set: &Par2FileSet,
5701    file_access: &mut dyn FileAccess,
5702    options: &RepairOptions,
5703    solver: &S,
5704) -> Result<()> {
5705    let n = plan.missing_slices.len();
5706    if n == 0 {
5707        return Ok(());
5708    }
5709
5710    let slice_size = plan.slice_size as usize;
5711    assert!(
5712        slice_size.is_multiple_of(2),
5713        "PAR2 slice_size must be a multiple of 2"
5714    );
5715    let word_count = slice_size / 2;
5716    let available_inputs = plan.available_input_global_indices.len();
5717    let total_inputs = available_inputs + plan.recovery_exponents.len();
5718    let total_inputs_u32 = total_inputs.min(u32::MAX as usize) as u32;
5719    let read_total_bytes = (total_inputs as u64).saturating_mul(slice_size as u64);
5720    let repair_total_bytes = (n as u64).saturating_mul(slice_size as u64);
5721    let write_total_bytes = (n as u64).saturating_mul(slice_size as u64);
5722    let operation_total_bytes = read_total_bytes
5723        .saturating_add(repair_total_bytes)
5724        .saturating_add(write_total_bytes);
5725    let mut input_buffers: Vec<Vec<u8>> = vec![vec![0u8; slice_size]; total_inputs];
5726    let mut repaired_slices: Vec<Vec<u8>> = vec![vec![0u8; slice_size]; n];
5727
5728    for (input_idx, &global_idx) in plan.available_input_global_indices.iter().enumerate() {
5729        if input_idx % 64 == 0 {
5730            check_cancel(options)?;
5731        }
5732
5733        let (file_id, local_slice) = plan.global_to_file[global_idx];
5734        let offset = local_slice as u64 * plan.slice_size;
5735        let read_len = file_access
5736            .read_file_range_into(&file_id, offset, &mut input_buffers[input_idx])
5737            .map_err(Par2Error::Io)?;
5738        input_buffers[input_idx][read_len..].fill(0);
5739
5740        if let Some(ref progress) = options.progress {
5741            progress(ProgressUpdate {
5742                stage: ProgressStage::Repairing,
5743                current: input_idx as u32 + 1,
5744                total: total_inputs_u32,
5745                bytes_processed: (input_idx + 1) as u64 * slice_size as u64,
5746                total_bytes: Some(operation_total_bytes),
5747                phase: ProgressPhase::Whole,
5748            });
5749        }
5750    }
5751
5752    for (recovery_idx, &exp) in plan.recovery_exponents.iter().enumerate() {
5753        check_cancel(options)?;
5754        let rs = par2_set
5755            .recovery_slices
5756            .get(&exp)
5757            .ok_or_else(|| Par2Error::ReedSolomonError {
5758                reason: format!("recovery block with exponent {exp} not found"),
5759            })?;
5760        let recovery_data = rs.data.to_vec().map_err(Par2Error::Io)?;
5761        let copy_len = recovery_data.len().min(slice_size);
5762        input_buffers[available_inputs + recovery_idx][..copy_len]
5763            .copy_from_slice(&recovery_data[..copy_len]);
5764        input_buffers[available_inputs + recovery_idx][copy_len..].fill(0);
5765
5766        if let Some(ref progress) = options.progress {
5767            let current = available_inputs + recovery_idx + 1;
5768            progress(ProgressUpdate {
5769                stage: ProgressStage::Repairing,
5770                current: current.min(u32::MAX as usize) as u32,
5771                total: total_inputs_u32,
5772                bytes_processed: current as u64 * slice_size as u64,
5773                total_bytes: Some(operation_total_bytes),
5774                phase: ProgressPhase::Whole,
5775            });
5776        }
5777    }
5778
5779    check_cancel(options)?;
5780
5781    // Reconstruct through the solver seam. The borrows of `input_buffers` and
5782    // `repaired_slices` are scoped so the slices are free to write afterwards.
5783    {
5784        let source_refs: Vec<&[u8]> = input_buffers.iter().map(|b| b.as_slice()).collect();
5785        let mut output_refs: Vec<&mut [u8]> = repaired_slices
5786            .iter_mut()
5787            .map(|b| b.as_mut_slice())
5788            .collect();
5789        let mut problem = RepairProblem {
5790            total_inputs: plan.total_input_slices,
5791            word_count,
5792            missing_indices: &plan.missing_global_indices,
5793            available_indices: &plan.available_input_global_indices,
5794            recovery_exponents: &plan.recovery_exponents,
5795            constants: &plan.constants,
5796            sources: &source_refs,
5797            outputs: &mut output_refs,
5798        };
5799        solver.reconstruct(&mut problem)?;
5800    }
5801
5802    if let Some(ref progress) = options.progress {
5803        progress(ProgressUpdate {
5804            stage: ProgressStage::Repairing,
5805            current: n as u32,
5806            total: n as u32,
5807            bytes_processed: read_total_bytes.saturating_add(repair_total_bytes),
5808            total_bytes: Some(operation_total_bytes),
5809            phase: ProgressPhase::Whole,
5810        });
5811    }
5812
5813    check_cancel(options)?;
5814    info!("writing repaired slices to files");
5815    let write_targets = build_write_targets(plan, par2_set)?;
5816    for (j, target) in write_targets.iter().enumerate() {
5817        check_cancel(options)?;
5818
5819        let slice_end = target.offset + plan.slice_size;
5820        let write_len = if slice_end > target.file_end {
5821            (target.file_end - target.offset) as usize
5822        } else {
5823            slice_size
5824        };
5825
5826        file_access
5827            .write_file_range(
5828                &target.file_id,
5829                target.offset,
5830                &repaired_slices[j][..write_len],
5831            )
5832            .map_err(|e| Par2Error::RepairWriteFailed {
5833                filename: target.filename.clone(),
5834                offset: target.offset,
5835                source: e,
5836            })?;
5837
5838        if let Some(ref progress) = options.progress {
5839            progress(ProgressUpdate {
5840                stage: ProgressStage::WritingRepaired,
5841                current: j as u32 + 1,
5842                total: n as u32,
5843                bytes_processed: read_total_bytes
5844                    .saturating_add(repair_total_bytes)
5845                    .saturating_add((j + 1) as u64 * slice_size as u64),
5846                total_bytes: Some(operation_total_bytes),
5847                phase: ProgressPhase::Whole,
5848            });
5849        }
5850    }
5851
5852    info!("repair complete: {} slices restored", n);
5853    Ok(())
5854}
5855
5856/// Execute a repair plan through a caller-provided [`RepairSolver`].
5857///
5858/// WebAssembly hosts may use this adapter to provide their own reconstruction
5859/// runtime. Native repair is exclusively driven by the streamed controller.
5860pub fn execute_repair_with_solver<S: RepairSolver + ?Sized>(
5861    plan: &RepairPlan,
5862    par2_set: &Par2FileSet,
5863    file_access: &mut dyn FileAccess,
5864    options: &RepairOptions,
5865    solver: &S,
5866) -> Result<()> {
5867    let n = plan.missing_slices.len();
5868    if n == 0 {
5869        return Ok(());
5870    }
5871    #[cfg(target_arch = "wasm32")]
5872    return run_in_memory_repair(plan, par2_set, file_access, options, solver);
5873
5874    #[cfg(not(target_arch = "wasm32"))]
5875    {
5876        let _ = (par2_set, file_access, options, solver);
5877        Err(Par2Error::ReedSolomonError {
5878            reason: "caller-provided in-memory PAR2 solvers are only supported on wasm32; native repair uses the streamed controller"
5879                .to_string(),
5880        })
5881    }
5882}
5883
5884/// Execute a repair plan with cancellation, progress, and memory budget support.
5885pub fn execute_repair_with_options(
5886    plan: &RepairPlan,
5887    par2_set: &Par2FileSet,
5888    file_access: &mut dyn FileAccess,
5889    options: &RepairOptions,
5890) -> Result<()> {
5891    let n = plan.missing_slices.len();
5892    if n == 0 {
5893        return Ok(());
5894    }
5895
5896    // Repair re-reads payload it already verified; evicting between those
5897    // passes forces physical re-reads (dominant on network block storage).
5898    let _cache_retention = crate::file_cache::CacheEvictionDeferral::acquire();
5899
5900    let slice_size = plan.slice_size as usize;
5901    assert!(
5902        slice_size.is_multiple_of(2),
5903        "PAR2 slice_size must be a multiple of 2"
5904    );
5905
5906    let budget = options.memory_limit.unwrap_or(DEFAULT_REPAIR_MEMORY_LIMIT);
5907
5908    // The transform arm is an alternative to the same CPU arithmetic the
5909    // streamed controller runs, under the same memory limit and the same write
5910    // path. It declines - writing nothing - whenever it is not admissible or
5911    // does not beat the dense fold count by a margin. `Diverged` means its own
5912    // per-band probe caught a disagreement: the dense executor then reruns the
5913    // whole plan, rewriting every band from sources the arm never wrote to.
5914    match crate::repair_transform::try_execute(plan, par2_set, file_access, options, budget)? {
5915        crate::repair_transform::TransformOutcome::Executed => return Ok(()),
5916        crate::repair_transform::TransformOutcome::Declined(reason) => {
5917            debug!(reason, "repair transform arm declined");
5918        }
5919        crate::repair_transform::TransformOutcome::Diverged => {
5920            warn!("repair transform arm diverged; rerunning the repair on the dense path");
5921        }
5922    }
5923
5924    execute_repair_streaming(plan, par2_set, file_access, options, budget)
5925}
5926
5927#[cfg(test)]
5928pub(crate) mod tests {
5929    use super::*;
5930    use crate::checksum::{self, SliceChecksumState};
5931    use crate::packet::header;
5932    use crate::par2_set::{Par2FileSet, RecoverySlice};
5933    use crate::types::SliceChecksum;
5934    use crate::verify::{self, FileStatus, FileVerification, MemoryFileAccess};
5935    use bytes::Bytes;
5936    use md5::{Digest, Md5};
5937    use tempfile::tempdir;
5938
5939    #[test]
5940    fn plain_output_bypasses_every_cpu_finalizer() {
5941        let assert_plain = |kernel| {
5942            let mut output: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
5943            let expected = output.clone();
5944            assert!(finalize_output_bytes(
5945                kernel,
5946                kernel.method(),
5947                OutputTransferSource::PlainContiguous(0).encoding(),
5948                &mut output,
5949            ));
5950            assert_eq!(output, expected, "plain output changed under {kernel:?}");
5951        };
5952
5953        for kernel in [CpuKernelKind::Plain, CpuKernelKind::Folded] {
5954            assert_plain(kernel);
5955        }
5956        #[cfg(target_arch = "x86_64")]
5957        if let Some(width) = reedsolomon_rs::xor_jit::JitWidth::detect() {
5958            assert_plain(CpuKernelKind::XorJit(width));
5959        }
5960    }
5961
5962    struct FailingReadAccess {
5963        inner: MemoryFileAccess,
5964        fail_after: usize,
5965        reads: std::sync::atomic::AtomicUsize,
5966    }
5967
5968    impl FileAccess for FailingReadAccess {
5969        fn read_file_range(
5970            &self,
5971            file_id: &FileId,
5972            offset: u64,
5973            len: u64,
5974        ) -> std::io::Result<Vec<u8>> {
5975            self.inner.read_file_range(file_id, offset, len)
5976        }
5977
5978        fn read_file_range_into(
5979            &self,
5980            file_id: &FileId,
5981            offset: u64,
5982            dst: &mut [u8],
5983        ) -> std::io::Result<usize> {
5984            let read = self
5985                .reads
5986                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5987            if read >= self.fail_after {
5988                return Err(std::io::Error::other("injected controller read failure"));
5989            }
5990            self.inner.read_file_range_into(file_id, offset, dst)
5991        }
5992
5993        fn file_exists(&self, file_id: &FileId) -> bool {
5994            self.inner.file_exists(file_id)
5995        }
5996
5997        fn file_length(&self, file_id: &FileId) -> Option<u64> {
5998            self.inner.file_length(file_id)
5999        }
6000
6001        fn read_file(&self, file_id: &FileId) -> std::io::Result<Vec<u8>> {
6002            self.inner.read_file(file_id)
6003        }
6004
6005        fn write_file_range(
6006            &mut self,
6007            file_id: &FileId,
6008            offset: u64,
6009            data: &[u8],
6010        ) -> std::io::Result<()> {
6011            self.inner.write_file_range(file_id, offset, data)
6012        }
6013    }
6014
6015    struct CountingRangeAccess {
6016        inner: MemoryFileAccess,
6017        range_opens: std::sync::atomic::AtomicUsize,
6018        fallback_reads: std::sync::atomic::AtomicUsize,
6019    }
6020
6021    impl FileAccess for CountingRangeAccess {
6022        fn read_file_range(
6023            &self,
6024            file_id: &FileId,
6025            offset: u64,
6026            len: u64,
6027        ) -> std::io::Result<Vec<u8>> {
6028            self.inner.read_file_range(file_id, offset, len)
6029        }
6030
6031        fn read_file_range_into(
6032            &self,
6033            file_id: &FileId,
6034            offset: u64,
6035            dst: &mut [u8],
6036        ) -> std::io::Result<usize> {
6037            self.fallback_reads
6038                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6039            self.inner.read_file_range_into(file_id, offset, dst)
6040        }
6041
6042        fn open_range_reader(
6043            &self,
6044            file_id: &FileId,
6045        ) -> std::io::Result<Option<Box<dyn FileRangeReader>>> {
6046            self.range_opens
6047                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6048            Ok(Some(Box::new(std::io::Cursor::new(
6049                self.inner.read_file(file_id)?,
6050            ))))
6051        }
6052
6053        fn file_exists(&self, file_id: &FileId) -> bool {
6054            self.inner.file_exists(file_id)
6055        }
6056
6057        fn file_length(&self, file_id: &FileId) -> Option<u64> {
6058            self.inner.file_length(file_id)
6059        }
6060
6061        fn read_file(&self, file_id: &FileId) -> std::io::Result<Vec<u8>> {
6062            self.inner.read_file(file_id)
6063        }
6064
6065        fn write_file_range(
6066            &mut self,
6067            file_id: &FileId,
6068            offset: u64,
6069            data: &[u8],
6070        ) -> std::io::Result<()> {
6071            self.inner.write_file_range(file_id, offset, data)
6072        }
6073    }
6074
6075    struct FailingWriteAccess {
6076        inner: MemoryFileAccess,
6077    }
6078
6079    impl FileAccess for FailingWriteAccess {
6080        fn read_file_range(
6081            &self,
6082            file_id: &FileId,
6083            offset: u64,
6084            len: u64,
6085        ) -> std::io::Result<Vec<u8>> {
6086            self.inner.read_file_range(file_id, offset, len)
6087        }
6088
6089        fn read_file_range_into(
6090            &self,
6091            file_id: &FileId,
6092            offset: u64,
6093            dst: &mut [u8],
6094        ) -> std::io::Result<usize> {
6095            self.inner.read_file_range_into(file_id, offset, dst)
6096        }
6097
6098        fn file_exists(&self, file_id: &FileId) -> bool {
6099            self.inner.file_exists(file_id)
6100        }
6101
6102        fn file_length(&self, file_id: &FileId) -> Option<u64> {
6103            self.inner.file_length(file_id)
6104        }
6105
6106        fn read_file(&self, file_id: &FileId) -> std::io::Result<Vec<u8>> {
6107            self.inner.read_file(file_id)
6108        }
6109
6110        fn write_file_range(
6111            &mut self,
6112            _file_id: &FileId,
6113            _offset: u64,
6114            _data: &[u8],
6115        ) -> std::io::Result<()> {
6116            Err(std::io::Error::other("injected controller write failure"))
6117        }
6118    }
6119
6120    /// Helper to build a complete valid packet (header + body).
6121    fn make_full_packet(packet_type: &[u8; 16], body: &[u8], recovery_set_id: [u8; 16]) -> Vec<u8> {
6122        let length = (header::HEADER_SIZE + body.len()) as u64;
6123        let mut hash_input = Vec::new();
6124        hash_input.extend_from_slice(&recovery_set_id);
6125        hash_input.extend_from_slice(packet_type);
6126        hash_input.extend_from_slice(body);
6127        let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
6128
6129        let mut data = Vec::new();
6130        data.extend_from_slice(header::MAGIC);
6131        data.extend_from_slice(&length.to_le_bytes());
6132        data.extend_from_slice(&packet_hash);
6133        data.extend_from_slice(&recovery_set_id);
6134        data.extend_from_slice(packet_type);
6135        data.extend_from_slice(body);
6136        data
6137    }
6138
6139    /// Create a PAR2 file set with known data and recovery blocks.
6140    ///
6141    /// Returns (par2_set, original_file_data, file_id).
6142    pub(crate) fn setup_repairable_set(
6143        file_data: &[u8],
6144        slice_size: u64,
6145        num_recovery: usize,
6146    ) -> (Par2FileSet, FileId) {
6147        let file_length = file_data.len() as u64;
6148        let hash_full = checksum::md5(file_data);
6149        let hash_16k_data = &file_data[..file_data.len().min(16384)];
6150        let hash_16k = checksum::md5(hash_16k_data);
6151
6152        let filename = b"testfile.dat";
6153        let mut id_input = Vec::new();
6154        id_input.extend_from_slice(&hash_16k);
6155        id_input.extend_from_slice(&file_length.to_le_bytes());
6156        id_input.extend_from_slice(filename);
6157        let file_id_bytes: [u8; 16] = Md5::digest(&id_input).into();
6158        let file_id = FileId::from_bytes(file_id_bytes);
6159
6160        let num_slices = if file_length == 0 {
6161            0
6162        } else {
6163            file_length.div_ceil(slice_size) as usize
6164        };
6165
6166        let mut checksums = Vec::new();
6167        for i in 0..num_slices {
6168            let offset = i as u64 * slice_size;
6169            let end = ((offset + slice_size) as usize).min(file_data.len());
6170            let slice_data = &file_data[offset as usize..end];
6171            let mut state = SliceChecksumState::new();
6172            state.update(slice_data);
6173            let pad_to = if (slice_data.len() as u64) < slice_size {
6174                Some(slice_size)
6175            } else {
6176                None
6177            };
6178            let (crc, md5) = state.finalize(pad_to);
6179            checksums.push(SliceChecksum { crc32: crc, md5 });
6180        }
6181
6182        // Build packets
6183        let mut main_body = Vec::new();
6184        main_body.extend_from_slice(&slice_size.to_le_bytes());
6185        main_body.extend_from_slice(&1u32.to_le_bytes());
6186        main_body.extend_from_slice(&file_id_bytes);
6187        let rsid: [u8; 16] = Md5::digest(&main_body).into();
6188
6189        let mut fd_body = Vec::new();
6190        fd_body.extend_from_slice(&file_id_bytes);
6191        fd_body.extend_from_slice(&hash_full);
6192        fd_body.extend_from_slice(&hash_16k);
6193        fd_body.extend_from_slice(&file_length.to_le_bytes());
6194        fd_body.extend_from_slice(filename);
6195        while fd_body.len() % 4 != 0 {
6196            fd_body.push(0);
6197        }
6198
6199        let mut ifsc_body = Vec::new();
6200        ifsc_body.extend_from_slice(&file_id_bytes);
6201        for cs in &checksums {
6202            ifsc_body.extend_from_slice(&cs.md5);
6203            ifsc_body.extend_from_slice(&cs.crc32.to_le_bytes());
6204        }
6205
6206        let mut stream = Vec::new();
6207        stream.extend_from_slice(&make_full_packet(header::TYPE_MAIN, &main_body, rsid));
6208        stream.extend_from_slice(&make_full_packet(header::TYPE_FILE_DESC, &fd_body, rsid));
6209        stream.extend_from_slice(&make_full_packet(header::TYPE_IFSC, &ifsc_body, rsid));
6210
6211        let mut set = Par2FileSet::from_files(&[&stream]).unwrap();
6212
6213        // Generate recovery blocks using the PAR2 encoding formula.
6214        let constants = gf::input_slice_constants(num_slices);
6215        let ss = slice_size as usize;
6216        let word_count = ss / 2;
6217
6218        // Pad file data to full slices.
6219        let mut padded = file_data.to_vec();
6220        padded.resize(num_slices * ss, 0);
6221
6222        for r in 0..num_recovery {
6223            let exp = r as u32;
6224            let mut recovery = vec![0u8; ss];
6225
6226            for (i, &constant) in constants.iter().enumerate() {
6227                let factor = gf::pow(constant, exp);
6228                for w in 0..word_count {
6229                    let input_word =
6230                        u16::from_le_bytes([padded[i * ss + w * 2], padded[i * ss + w * 2 + 1]]);
6231                    let contribution = gf::mul(input_word, factor);
6232                    let rec_word = u16::from_le_bytes([recovery[w * 2], recovery[w * 2 + 1]]);
6233                    let new_val = gf::add(rec_word, contribution);
6234                    let bytes = new_val.to_le_bytes();
6235                    recovery[w * 2] = bytes[0];
6236                    recovery[w * 2 + 1] = bytes[1];
6237                }
6238            }
6239
6240            set.recovery_slices.insert(
6241                exp,
6242                RecoverySlice {
6243                    exponent: exp,
6244                    data: Bytes::from(recovery).into(),
6245                },
6246            );
6247        }
6248
6249        (set, file_id)
6250    }
6251
6252    fn spill_recovery_slices_to_disk(set: &mut Par2FileSet) -> tempfile::TempDir {
6253        let dir = tempdir().unwrap();
6254        for (exp, slice) in &mut set.recovery_slices {
6255            let path = dir.path().join(format!("recovery_{exp}.bin"));
6256            let bytes = slice.data.to_vec().unwrap();
6257            std::fs::write(&path, &bytes).unwrap();
6258            slice.data = crate::packet::RecoverySliceData::file_backed(path, 0, bytes.len());
6259        }
6260        dir
6261    }
6262
6263    /// Like [`spill_recovery_slices_to_disk`], but records the PAR2 packet
6264    /// hash the streaming scanner would have captured, enabling lazy payload
6265    /// validation.
6266    fn spill_recovery_slices_to_disk_with_hashes(set: &mut Par2FileSet) -> tempfile::TempDir {
6267        let dir = tempdir().unwrap();
6268        let rsid = *set.recovery_set_id.as_bytes();
6269        for (exp, slice) in &mut set.recovery_slices {
6270            let path = dir.path().join(format!("recovery_{exp}.bin"));
6271            let bytes = slice.data.to_vec().unwrap();
6272            std::fs::write(&path, &bytes).unwrap();
6273
6274            let mut hash_input = Vec::new();
6275            hash_input.extend_from_slice(&rsid);
6276            hash_input.extend_from_slice(header::TYPE_RECOVERY);
6277            hash_input.extend_from_slice(&exp.to_le_bytes());
6278            hash_input.extend_from_slice(&bytes);
6279            let packet_hash: [u8; 16] = Md5::digest(&hash_input).into();
6280
6281            slice.data = crate::packet::RecoverySliceData::file_backed_with_hash(
6282                path,
6283                0,
6284                bytes.len(),
6285                packet_hash,
6286            );
6287        }
6288        dir
6289    }
6290
6291    #[test]
6292    fn plan_repair_skips_recovery_blocks_with_corrupt_payloads() {
6293        let slice_size = 64u64;
6294        let file_data: Vec<u8> = (0..256u32).map(|i| ((i * 11 + 3) % 256) as u8).collect();
6295        let (mut par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
6296        let spill_dir = spill_recovery_slices_to_disk_with_hashes(&mut par2_set);
6297
6298        // Corrupt the payloads of the two lowest exponents on disk. Selection
6299        // prefers low exponents, so the plan must detect the damage and fall
6300        // back to the clean blocks.
6301        for exp in [0u32, 1] {
6302            let path = spill_dir.path().join(format!("recovery_{exp}.bin"));
6303            let mut bytes = std::fs::read(&path).unwrap();
6304            bytes[7] ^= 0xFF;
6305            std::fs::write(&path, &bytes).unwrap();
6306        }
6307
6308        let mut damaged = file_data.clone();
6309        damaged[..64].fill(0);
6310        damaged[128..192].fill(0);
6311
6312        let mut access = MemoryFileAccess::new();
6313        access.add_file(file_id, damaged);
6314
6315        let result = verify::verify_all(&par2_set, &access);
6316        assert_eq!(result.total_missing_blocks, 2);
6317
6318        let plan = plan_repair(&par2_set, &result).unwrap();
6319        assert!(!plan.recovery_exponents.contains(&0));
6320        assert!(!plan.recovery_exponents.contains(&1));
6321
6322        execute_repair(&plan, &par2_set, &mut access).unwrap();
6323        let repaired = access.read_file(&file_id).unwrap();
6324        assert_eq!(repaired, file_data);
6325    }
6326
6327    #[test]
6328    fn plan_repair_fails_when_all_recovery_payloads_are_corrupt() {
6329        let slice_size = 64u64;
6330        let file_data: Vec<u8> = (0..256u32).map(|i| ((i * 5 + 1) % 256) as u8).collect();
6331        let (mut par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6332        let spill_dir = spill_recovery_slices_to_disk_with_hashes(&mut par2_set);
6333
6334        for exp in [0u32, 1] {
6335            let path = spill_dir.path().join(format!("recovery_{exp}.bin"));
6336            let mut bytes = std::fs::read(&path).unwrap();
6337            bytes[0] ^= 0x01;
6338            std::fs::write(&path, &bytes).unwrap();
6339        }
6340
6341        let mut damaged = file_data.clone();
6342        damaged[..64].fill(0);
6343
6344        let mut access = MemoryFileAccess::new();
6345        access.add_file(file_id, damaged);
6346
6347        let result = verify::verify_all(&par2_set, &access);
6348        let err = plan_repair(&par2_set, &result).unwrap_err();
6349        assert!(matches!(err, Par2Error::InsufficientRecoveryData { .. }));
6350    }
6351
6352    #[test]
6353    fn end_to_end_repair_single_damaged_slice() {
6354        // Create a file with 4 slices of 64 bytes each (256 bytes total).
6355        let slice_size = 64u64;
6356        let file_data: Vec<u8> = (0..256u32).map(|i| (i % 256) as u8).collect();
6357        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6358
6359        // Set up access with damaged data (corrupt slice 2).
6360        let mut damaged = file_data.clone();
6361        for item in damaged.iter_mut().take(192).skip(128) {
6362            *item ^= 0xFF;
6363        }
6364
6365        let mut access = MemoryFileAccess::new();
6366        access.add_file(file_id, damaged);
6367
6368        // Verify.
6369        let result = verify::verify_all(&par2_set, &access);
6370        assert_eq!(result.total_missing_blocks, 1);
6371        assert!(matches!(
6372            result.repairable,
6373            Repairability::Repairable { .. }
6374        ));
6375
6376        // Plan repair.
6377        let plan = plan_repair(&par2_set, &result).unwrap();
6378        assert_eq!(plan.missing_slices.len(), 1);
6379        assert_eq!(plan.missing_slices[0], (file_id, 2));
6380
6381        // Execute repair.
6382        execute_repair(&plan, &par2_set, &mut access).unwrap();
6383
6384        // Verify the repaired data matches original.
6385        let repaired = access.read_file(&file_id).unwrap();
6386        assert_eq!(repaired, file_data, "repaired data should match original");
6387    }
6388
6389    #[test]
6390    fn end_to_end_repair_multiple_damaged_slices() {
6391        let slice_size = 32u64;
6392        let file_data: Vec<u8> = (0..128u32).map(|i| ((i * 7 + 13) % 256) as u8).collect();
6393        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 3);
6394
6395        // Damage slices 0 and 3 (out of 4 total).
6396        let mut damaged = file_data.clone();
6397        for item in damaged.iter_mut().take(32) {
6398            *item = 0;
6399        }
6400        for item in damaged.iter_mut().take(128).skip(96) {
6401            *item = 0;
6402        }
6403
6404        let mut access = MemoryFileAccess::new();
6405        access.add_file(file_id, damaged);
6406
6407        let result = verify::verify_all(&par2_set, &access);
6408        assert_eq!(result.total_missing_blocks, 2);
6409
6410        let plan = plan_repair(&par2_set, &result).unwrap();
6411        assert_eq!(plan.missing_slices.len(), 2);
6412
6413        execute_repair(&plan, &par2_set, &mut access).unwrap();
6414
6415        let repaired = access.read_file(&file_id).unwrap();
6416        assert_eq!(repaired, file_data);
6417    }
6418
6419    #[test]
6420    fn end_to_end_repair_missing_file() {
6421        // Test repairing a completely missing file.
6422        let slice_size = 64u64;
6423        let file_data: Vec<u8> = (0..128u32).map(|i| (i % 256) as u8).collect();
6424        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
6425
6426        // File is completely missing -- create with zeros so write_file_range works.
6427        let mut access = MemoryFileAccess::new();
6428        access.add_file(file_id, vec![0u8; 128]);
6429
6430        let result = verify::verify_all(&par2_set, &access);
6431        assert_eq!(result.total_missing_blocks, 2); // 2 slices, both damaged
6432
6433        let plan = plan_repair(&par2_set, &result).unwrap();
6434        assert_eq!(plan.missing_slices.len(), 2);
6435
6436        execute_repair(&plan, &par2_set, &mut access).unwrap();
6437
6438        let repaired = access.read_file(&file_id).unwrap();
6439        assert_eq!(repaired, file_data);
6440    }
6441
6442    #[test]
6443    fn plan_repair_not_needed() {
6444        let slice_size = 64u64;
6445        let file_data = vec![0xABu8; 128];
6446        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6447
6448        let mut access = MemoryFileAccess::new();
6449        access.add_file(file_id, file_data);
6450
6451        let result = verify::verify_all(&par2_set, &access);
6452        let err = plan_repair(&par2_set, &result).unwrap_err();
6453        assert!(matches!(err, Par2Error::ReedSolomonError { .. }));
6454    }
6455
6456    #[test]
6457    fn plan_repair_insufficient() {
6458        let slice_size = 64u64;
6459        let file_data: Vec<u8> = (0..256u32).map(|i| (i % 256) as u8).collect();
6460        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 1);
6461
6462        // Damage 2 slices but only 1 recovery block.
6463        let mut damaged = file_data.clone();
6464        for item in damaged.iter_mut().take(64) {
6465            *item = 0;
6466        }
6467        for item in damaged.iter_mut().take(128).skip(64) {
6468            *item = 0;
6469        }
6470
6471        let mut access = MemoryFileAccess::new();
6472        access.add_file(file_id, damaged);
6473
6474        let result = verify::verify_all(&par2_set, &access);
6475        let err = plan_repair(&par2_set, &result).unwrap_err();
6476        assert!(matches!(err, Par2Error::InsufficientRecoveryData { .. }));
6477    }
6478
6479    #[test]
6480    fn plan_repair_rejects_resource_limited_verification() {
6481        let slice_size = 64u64;
6482        let file_data = vec![0xABu8; 128];
6483        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6484        let result = VerificationResult {
6485            files: vec![FileVerification {
6486                file_id,
6487                filename: "testfile.dat".to_string(),
6488                status: FileStatus::Damaged(0),
6489                valid_slices: Vec::new(),
6490                missing_slice_count: 0,
6491            }],
6492            recovery_blocks_available: 2,
6493            total_missing_blocks: 0,
6494            repairable: Repairability::ResourceLimited {
6495                reason: "file testfile.dat exceeds verifier slice limits".to_string(),
6496            },
6497        };
6498
6499        let err = plan_repair(&par2_set, &result).unwrap_err();
6500        assert!(matches!(err, Par2Error::ResourceLimitExceeded { .. }));
6501    }
6502
6503    #[test]
6504    fn matrix_memory_budget_has_floor_but_still_caps() {
6505        // A tiny slice-buffer limit no longer starves the transient decode
6506        // matrix: planning small repairs succeeds even at absurd limits.
6507        assert!(repair_matrix_limit_reason(4, 2, Some(8)).is_none());
6508
6509        // A matrix that exceeds even the budget floor is still refused...
6510        let missing = 20_000usize;
6511        let reason = repair_matrix_limit_reason(32_768, missing, Some(8)).unwrap();
6512        assert!(reason.contains("matrix workspace budget"));
6513
6514        // ...unless the caller raises the limit explicitly.
6515        assert!(repair_matrix_limit_reason(32_768, missing, Some(8 << 30)).is_none());
6516
6517        // Sets beyond the PAR2 total-slice cap are reported as such.
6518        let reason = repair_matrix_limit_reason(40_000, 1, None).unwrap();
6519        assert!(reason.contains("at most"));
6520    }
6521
6522    #[test]
6523    fn plan_repair_succeeds_with_tiny_configured_memory_limit() {
6524        let slice_size = 64u64;
6525        let file_data: Vec<u8> = (0..256u32).map(|i| (i % 256) as u8).collect();
6526        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 3);
6527
6528        let mut damaged = file_data.clone();
6529        damaged[..64].fill(0);
6530        damaged[64..128].fill(0);
6531
6532        let mut access = MemoryFileAccess::new();
6533        access.add_file(file_id, damaged);
6534
6535        let result = verify::verify_all(&par2_set, &access);
6536        assert_eq!(result.total_missing_blocks, 2);
6537
6538        let plan = plan_repair_with_memory_limit(&par2_set, &result, Some(8)).unwrap();
6539        assert_eq!(plan.missing_slices.len(), 2);
6540    }
6541
6542    #[test]
6543    fn plan_repair_rejects_sets_over_total_slice_limit() {
6544        let slice_size = 4u64;
6545        let slices_per_file = 20_000u64;
6546        let mut files = HashMap::new();
6547        let mut recovery_file_ids = Vec::new();
6548        let mut verifications = Vec::new();
6549        for index in 0..2u8 {
6550            let file_id = FileId::from_bytes([index + 1; 16]);
6551            recovery_file_ids.push(file_id);
6552            files.insert(
6553                file_id,
6554                crate::par2_set::FileDescription {
6555                    file_id,
6556                    hash_full: [0; 16],
6557                    hash_16k: [0; 16],
6558                    length: slice_size * slices_per_file,
6559                    par2_name: format!("big{index}.dat"),
6560                    filename: format!("big{index}.dat"),
6561                },
6562            );
6563            let mut valid_slices = vec![true; slices_per_file as usize];
6564            if index == 0 {
6565                valid_slices[0] = false;
6566            }
6567            verifications.push(FileVerification {
6568                file_id,
6569                filename: format!("big{index}.dat"),
6570                status: if index == 0 {
6571                    FileStatus::Damaged(1)
6572                } else {
6573                    FileStatus::Complete
6574                },
6575                missing_slice_count: u32::from(index == 0),
6576                valid_slices,
6577            });
6578        }
6579
6580        let mut recovery_slices = std::collections::BTreeMap::new();
6581        recovery_slices.insert(
6582            0,
6583            RecoverySlice {
6584                exponent: 0,
6585                data: Bytes::from(vec![0u8; slice_size as usize]).into(),
6586            },
6587        );
6588        let par2_set = Par2FileSet {
6589            recovery_set_id: crate::types::RecoverySetId::from_bytes([9; 16]),
6590            slice_size,
6591            recovery_file_ids,
6592            non_recovery_file_ids: Vec::new(),
6593            files,
6594            slice_checksums: HashMap::new(),
6595            recovery_slices,
6596            creator: None,
6597        };
6598        let result = VerificationResult {
6599            files: verifications,
6600            recovery_blocks_available: 1,
6601            total_missing_blocks: 1,
6602            repairable: Repairability::Repairable {
6603                blocks_needed: 1,
6604                blocks_available: 1,
6605            },
6606        };
6607
6608        let err = plan_repair(&par2_set, &result).unwrap_err();
6609        assert!(matches!(err, Par2Error::ResourceLimitExceeded { .. }));
6610    }
6611
6612    #[test]
6613    fn repair_with_partial_last_slice() {
6614        // File size not a multiple of slice_size.
6615        let slice_size = 64u64;
6616        // 100 bytes = 2 slices (64 + 36), last slice padded to 64 for RS.
6617        let file_data: Vec<u8> = (0..100u32).map(|i| ((i * 3 + 5) % 256) as u8).collect();
6618        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6619
6620        // Damage the last slice.
6621        let mut damaged = file_data.clone();
6622        for item in damaged.iter_mut().take(100).skip(64) {
6623            *item = 0;
6624        }
6625
6626        let mut access = MemoryFileAccess::new();
6627        access.add_file(file_id, damaged);
6628
6629        let result = verify::verify_all(&par2_set, &access);
6630        assert_eq!(result.total_missing_blocks, 1);
6631
6632        let plan = plan_repair(&par2_set, &result).unwrap();
6633        execute_repair(&plan, &par2_set, &mut access).unwrap();
6634
6635        let repaired = access.read_file(&file_id).unwrap();
6636        assert_eq!(repaired, file_data);
6637    }
6638
6639    #[test]
6640    fn repair_with_tiny_memory_limit_still_succeeds() {
6641        let slice_size = 128u64;
6642        let file_data: Vec<u8> = (0..384u32).map(|i| ((i * 9 + 17) % 256) as u8).collect();
6643        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 3);
6644
6645        let mut damaged = file_data.clone();
6646        for item in damaged.iter_mut().take(128) {
6647            *item = 0;
6648        }
6649        for item in damaged.iter_mut().take(384).skip(256) {
6650            *item = 0;
6651        }
6652
6653        let mut access = MemoryFileAccess::new();
6654        access.add_file(file_id, damaged);
6655
6656        let result = verify::verify_all(&par2_set, &access);
6657        let plan = plan_repair(&par2_set, &result).unwrap();
6658
6659        execute_repair_with_options(
6660            &plan,
6661            &par2_set,
6662            &mut access,
6663            &RepairOptions {
6664                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
6665                ..RepairOptions::default()
6666            },
6667        )
6668        .unwrap();
6669
6670        let repaired = access.read_file(&file_id).unwrap();
6671        assert_eq!(repaired, file_data);
6672    }
6673
6674    #[test]
6675    fn streaming_controller_rotates_two_full_groups_and_flushes_partial_group() {
6676        let slice_size = 64u64;
6677        let file_data: Vec<u8> = (0..25 * slice_size as u32)
6678            .map(|i| ((i * 29 + 7) % 251) as u8)
6679            .collect();
6680        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
6681
6682        let mut damaged = file_data.clone();
6683        for slice in [0usize, 12, 24] {
6684            let start = slice * slice_size as usize;
6685            damaged[start..start + slice_size as usize].fill(0);
6686        }
6687        let mut access = MemoryFileAccess::new();
6688        access.add_file(file_id, damaged);
6689
6690        let verification = verify::verify_all(&par2_set, &access);
6691        let plan = plan_repair(&par2_set, &verification).unwrap();
6692        assert_eq!(plan.input_factors.cols, 25);
6693        let trace = ControllerExecutionTrace::capture();
6694        execute_repair_streaming_with_trace(
6695            &plan,
6696            &par2_set,
6697            &mut access,
6698            &RepairOptions {
6699                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
6700                ..RepairOptions::default()
6701            },
6702            constrained_memory_limit_for_test(&plan),
6703            trace.clone(),
6704        )
6705        .unwrap();
6706
6707        assert_eq!(access.read_file(&file_id).unwrap(), file_data);
6708        let events = trace.events();
6709        let reads = events
6710            .iter()
6711            .filter(|event| matches!(event, ControllerExecutionEvent::SourceRead { .. }))
6712            .count();
6713        let queued = events
6714            .iter()
6715            .filter(|event| matches!(event, ControllerExecutionEvent::InputQueued { .. }))
6716            .count();
6717        let prepared = events
6718            .iter()
6719            .filter(|event| matches!(event, ControllerExecutionEvent::PreparationCompleted { .. }))
6720            .count();
6721        let submitted = events
6722            .iter()
6723            .filter(|event| matches!(event, ControllerExecutionEvent::ComputeSubmitted { .. }))
6724            .count();
6725        let completed = events
6726            .iter()
6727            .filter(|event| matches!(event, ControllerExecutionEvent::ComputeCompleted { .. }))
6728            .count();
6729        let lifecycle_submitted = events
6730            .iter()
6731            .filter(|event| matches!(event, ControllerExecutionEvent::BatchSubmitted { .. }))
6732            .count();
6733        let rotations = events
6734            .iter()
6735            .filter(|event| matches!(event, ControllerExecutionEvent::StagingRotated { .. }))
6736            .count();
6737        assert!(reads > 12);
6738        assert_eq!(reads, queued);
6739        assert!(prepared > 2);
6740        assert_eq!(prepared, submitted);
6741        assert_eq!(submitted, completed);
6742        assert_eq!(lifecycle_submitted, submitted);
6743        assert_eq!(rotations, lifecycle_submitted);
6744        assert!(
6745            events
6746                .iter()
6747                .all(|event| !matches!(event, ControllerExecutionEvent::Failed { .. }))
6748        );
6749        for staging_area in 0..2 {
6750            let prepared = events
6751                .iter()
6752                .position(|event| matches!(event, ControllerExecutionEvent::PreparationCompleted { staging_area: area, .. } if *area == staging_area));
6753            let submitted = events
6754                .iter()
6755                .position(|event| matches!(event, ControllerExecutionEvent::ComputeSubmitted { staging_area: area, .. } if *area == staging_area));
6756            if let (Some(prepared), Some(submitted)) = (prepared, submitted) {
6757                assert!(submitted < prepared);
6758            }
6759        }
6760        let first_wait = events
6761            .iter()
6762            .position(|event| matches!(event, ControllerExecutionEvent::WaitForAdd { .. }))
6763            .expect("two active staging areas trigger waitForAdd");
6764        assert!(
6765            events[..first_wait]
6766                .iter()
6767                .filter(|event| matches!(event, ControllerExecutionEvent::BatchSubmitted { .. }))
6768                .count()
6769                >= 2,
6770            "the live controller must not wait after submitting only one staging area"
6771        );
6772        assert!(events[..first_wait].iter().any(|event| matches!(
6773            event,
6774            ControllerExecutionEvent::SourceRead {
6775                source_index: 24,
6776                staging_area: 0,
6777            }
6778        )));
6779        let input_ended = events
6780            .iter()
6781            .position(|event| matches!(event, ControllerExecutionEvent::InputEnded { .. }))
6782            .expect("input lifecycle ended");
6783        let processing_finished = events
6784            .iter()
6785            .position(|event| matches!(event, ControllerExecutionEvent::ProcessingFinished))
6786            .expect("processing lifecycle finished");
6787        assert!(input_ended < processing_finished);
6788    }
6789
6790    #[test]
6791    fn streaming_controller_read_failure_does_not_accept_partial_batch() {
6792        let slice_size = 64u64;
6793        let file_data: Vec<u8> = (0..25 * slice_size as u32)
6794            .map(|i| ((i * 17 + 5) % 251) as u8)
6795            .collect();
6796        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
6797        let mut damaged = file_data.clone();
6798        for slice in [0usize, 12, 24] {
6799            let start = slice * slice_size as usize;
6800            damaged[start..start + slice_size as usize].fill(0);
6801        }
6802
6803        let mut verification_access = MemoryFileAccess::new();
6804        verification_access.add_file(file_id, damaged.clone());
6805        let verification = verify::verify_all(&par2_set, &verification_access);
6806        let plan = plan_repair(&par2_set, &verification).unwrap();
6807
6808        let mut inner = MemoryFileAccess::new();
6809        inner.add_file(file_id, damaged.clone());
6810        let mut access = FailingReadAccess {
6811            inner,
6812            fail_after: 1,
6813            reads: std::sync::atomic::AtomicUsize::new(0),
6814        };
6815        let trace = ControllerExecutionTrace::capture();
6816        let error = execute_repair_streaming_with_trace(
6817            &plan,
6818            &par2_set,
6819            &mut access,
6820            &RepairOptions {
6821                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
6822                ..RepairOptions::default()
6823            },
6824            constrained_memory_limit_for_test(&plan),
6825            trace.clone(),
6826        )
6827        .unwrap_err();
6828
6829        assert!(matches!(error, Par2Error::Io(_)));
6830        assert_eq!(access.read_file(&file_id).unwrap(), damaged);
6831        assert!(trace.events().iter().any(|event| matches!(
6832            event,
6833            ControllerExecutionEvent::Failed {
6834                phase: ControllerFailurePhase::Read
6835            }
6836        )));
6837    }
6838
6839    #[test]
6840    fn streaming_controller_honors_cancellation_before_mutation() {
6841        let slice_size = 64u64;
6842        let file_data: Vec<u8> = (0..13 * slice_size as u32)
6843            .map(|i| ((i * 13 + 11) % 251) as u8)
6844            .collect();
6845        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6846        let mut damaged = file_data.clone();
6847        damaged[..slice_size as usize].fill(0);
6848
6849        let mut access = MemoryFileAccess::new();
6850        access.add_file(file_id, damaged.clone());
6851        let verification = verify::verify_all(&par2_set, &access);
6852        let plan = plan_repair(&par2_set, &verification).unwrap();
6853        let cancel = CancellationToken::new();
6854        cancel.cancel();
6855        let error = execute_repair_with_options(
6856            &plan,
6857            &par2_set,
6858            &mut access,
6859            &RepairOptions {
6860                memory_limit: Some(256),
6861                cancel: Some(cancel),
6862                ..RepairOptions::default()
6863            },
6864        )
6865        .unwrap_err();
6866
6867        assert!(matches!(error, Par2Error::Cancelled));
6868        assert_eq!(access.read_file(&file_id).unwrap(), damaged);
6869    }
6870
6871    #[test]
6872    fn streaming_controller_reuses_seekable_source_reader() {
6873        let slice_size = 64u64;
6874        let file_data: Vec<u8> = (0..25 * slice_size as u32)
6875            .map(|i| ((i * 7 + 19) % 251) as u8)
6876            .collect();
6877        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
6878        let mut damaged = file_data.clone();
6879        for slice in [0usize, 12, 24] {
6880            let start = slice * slice_size as usize;
6881            damaged[start..start + slice_size as usize].fill(0);
6882        }
6883
6884        let mut verification_access = MemoryFileAccess::new();
6885        verification_access.add_file(file_id, damaged.clone());
6886        let verification = verify::verify_all(&par2_set, &verification_access);
6887        let plan = plan_repair(&par2_set, &verification).unwrap();
6888
6889        let mut inner = MemoryFileAccess::new();
6890        inner.add_file(file_id, damaged);
6891        let mut access = CountingRangeAccess {
6892            inner,
6893            range_opens: std::sync::atomic::AtomicUsize::new(0),
6894            fallback_reads: std::sync::atomic::AtomicUsize::new(0),
6895        };
6896        execute_repair_with_options(
6897            &plan,
6898            &par2_set,
6899            &mut access,
6900            &RepairOptions {
6901                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
6902                ..RepairOptions::default()
6903            },
6904        )
6905        .unwrap();
6906
6907        assert!(
6908            access
6909                .range_opens
6910                .load(std::sync::atomic::Ordering::Relaxed)
6911                > 0
6912        );
6913        assert_eq!(
6914            access
6915                .fallback_reads
6916                .load(std::sync::atomic::Ordering::Relaxed),
6917            0
6918        );
6919        assert_eq!(access.read_file(&file_id).unwrap(), file_data);
6920    }
6921
6922    #[test]
6923    fn streaming_controller_output_failure_is_not_accepted() {
6924        let slice_size = 64u64;
6925        let file_data: Vec<u8> = (0..13 * slice_size as u32)
6926            .map(|i| ((i * 31 + 3) % 251) as u8)
6927            .collect();
6928        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 2);
6929        let mut damaged = file_data.clone();
6930        damaged[..slice_size as usize].fill(0);
6931
6932        let mut verification_access = MemoryFileAccess::new();
6933        verification_access.add_file(file_id, damaged.clone());
6934        let verification = verify::verify_all(&par2_set, &verification_access);
6935        let plan = plan_repair(&par2_set, &verification).unwrap();
6936
6937        let mut inner = MemoryFileAccess::new();
6938        inner.add_file(file_id, damaged.clone());
6939        let mut access = FailingWriteAccess { inner };
6940        let trace = ControllerExecutionTrace::capture();
6941        let error = execute_repair_streaming_with_trace(
6942            &plan,
6943            &par2_set,
6944            &mut access,
6945            &RepairOptions {
6946                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
6947                ..RepairOptions::default()
6948            },
6949            constrained_memory_limit_for_test(&plan),
6950            trace.clone(),
6951        )
6952        .unwrap_err();
6953
6954        assert!(matches!(error, Par2Error::RepairWriteFailed { .. }));
6955        assert_eq!(access.read_file(&file_id).unwrap(), damaged);
6956        assert!(trace.events().iter().any(|event| matches!(
6957            event,
6958            ControllerExecutionEvent::Failed {
6959                phase: ControllerFailurePhase::Write
6960            }
6961        )));
6962    }
6963
6964    #[test]
6965    fn compute_worker_failure_is_reported_before_output_transfer() {
6966        let factors = matrix::Matrix::identity(1);
6967        let memo = PreparedFactorMemo::from_matrix(&factors, false);
6968        let mut set = StreamBatchSet::new(2, 1, 1, 1, false, false, false);
6969        set.len = 1;
6970        set.coefficients[0] = 1;
6971        let layout = ControllerLayout {
6972            aligned_len: 4,
6973            chunk_len: 4,
6974            num_chunks: 1,
6975            assignments: vec![crate::cpu_repair_controller::WorkAssignment {
6976                worker: 0,
6977                byte_start: 0,
6978                byte_len: 4,
6979                output_start: 0,
6980                output_len: 1,
6981            }],
6982            worker_count: 1,
6983            stride: 2,
6984        };
6985        let mut output = vec![0x5au8; 4];
6986        let trace = ControllerExecutionTrace::capture();
6987        let context = Arc::new(CpuComputeContext {
6988            output_base: output.as_mut_ptr() as usize,
6989            output_count: 1,
6990            set,
6991            memo: &memo,
6992            #[cfg(target_arch = "x86_64")]
6993            jit_memo: None,
6994            #[cfg(target_arch = "x86_64")]
6995            jit_batch: None,
6996            layout: Arc::new(layout),
6997            method: CpuKernelKind::Plain.method(),
6998            trace: trace.clone(),
6999            folded_coefficients: FoldedBatchCoefficients::None,
7000            add: false,
7001        });
7002
7003        std::thread::scope(|scope| {
7004            let (job_tx, job_rx) = std::sync::mpsc::sync_channel(1);
7005            let (completion_tx, completion_rx) = std::sync::mpsc::sync_channel(1);
7006            let worker = scope.spawn(move || run_compute_worker(0, job_rx, completion_tx));
7007            job_tx
7008                .send(CpuComputeJob {
7009                    id: 7,
7010                    context: Arc::clone(&context),
7011                })
7012                .unwrap();
7013            drop(job_tx);
7014            let completion = completion_rx.recv().unwrap();
7015            assert_eq!(completion.id, 7);
7016            assert!(completion.failure.is_some());
7017            worker.join().unwrap();
7018        });
7019        assert_eq!(output, vec![0x5a; 4]);
7020        assert!(trace.events().iter().any(|event| matches!(
7021            event,
7022            ControllerExecutionEvent::Failed {
7023                phase: ControllerFailurePhase::Compute
7024            }
7025        )));
7026    }
7027
7028    #[test]
7029    fn folded_coefficient_one_add_preserves_split_blocks() {
7030        let mut destination = vec![0x11; crate::gf_simd::SPLIT_BLOCK_BYTES * 2];
7031        let mut staging = vec![0u8; destination.len() * crate::gf_simd::FOLDED_GROUP];
7032        for (index, byte) in staging.iter_mut().enumerate() {
7033            *byte = index as u8;
7034        }
7035        let expected = destination
7036            .iter()
7037            .enumerate()
7038            .map(|(index, byte)| {
7039                (0..crate::gf_simd::FOLDED_GROUP).fold(*byte, |value, lane| {
7040                    value
7041                        ^ staging[index / crate::gf_simd::SPLIT_BLOCK_BYTES
7042                            * crate::gf_simd::FOLDED_GROUP
7043                            * crate::gf_simd::SPLIT_BLOCK_BYTES
7044                            + (index % crate::gf_simd::SPLIT_BLOCK_BYTES)
7045                            + lane * crate::gf_simd::SPLIT_BLOCK_BYTES]
7046                })
7047            })
7048            .collect::<Vec<_>>();
7049        xor_folded_group_into(&mut destination, &staging, crate::gf_simd::FOLDED_GROUP);
7050        assert_eq!(destination, expected);
7051    }
7052
7053    #[test]
7054    fn compute_wait_drains_workers_before_returning_cancelled() {
7055        let (completion_tx, completion_rx) = std::sync::mpsc::sync_channel(2);
7056        for worker in 0..2 {
7057            completion_tx
7058                .send(CpuComputeCompletion {
7059                    id: 17,
7060                    worker,
7061                    elapsed: Duration::ZERO,
7062                    failure: None,
7063                })
7064                .unwrap();
7065        }
7066        let cancel = CancellationToken::new();
7067        cancel.cancel();
7068        let factors = matrix::Matrix::identity(1);
7069        let memo = PreparedFactorMemo::from_matrix(&factors, false);
7070        let context = Arc::new(CpuComputeContext {
7071            output_base: std::ptr::NonNull::<u8>::dangling().as_ptr() as usize,
7072            output_count: 0,
7073            set: StreamBatchSet::new(2, 1, 1, 1, false, false, false),
7074            memo: &memo,
7075            #[cfg(target_arch = "x86_64")]
7076            jit_memo: None,
7077            #[cfg(target_arch = "x86_64")]
7078            jit_batch: None,
7079            layout: Arc::new(ControllerLayout {
7080                aligned_len: 2,
7081                chunk_len: 2,
7082                num_chunks: 1,
7083                assignments: Vec::new(),
7084                worker_count: 1,
7085                stride: 2,
7086            }),
7087            method: CpuKernelKind::Plain.method(),
7088            trace: ControllerExecutionTrace::default(),
7089            folded_coefficients: FoldedBatchCoefficients::None,
7090            add: false,
7091        });
7092        let mut pool = CpuComputePool {
7093            completion_rx,
7094            deferred: HashMap::new(),
7095            _lifetime: std::marker::PhantomData,
7096        };
7097        let error = match pool.wait(
7098            CpuComputeTicket {
7099                id: 17,
7100                expected: 2,
7101                submission_failure: None,
7102                context,
7103            },
7104            Some(&cancel),
7105            &CpuControllerTimings::default(),
7106        ) {
7107            Ok(_) => panic!("cancelled compute wait unexpectedly succeeded"),
7108            Err(error) => error,
7109        };
7110        assert!(matches!(error, Par2Error::Cancelled));
7111    }
7112
7113    #[test]
7114    fn preparation_failure_closes_batch_without_output() {
7115        let set = StreamBatchSet::new(64, 12, 12, 1, false, false, false);
7116        let trace = ControllerExecutionTrace::capture();
7117        let worker_trace = trace.clone();
7118        let factors = matrix::Matrix::identity(1);
7119        let memo = PreparedFactorMemo::from_matrix(&factors, false);
7120        let timings = CpuControllerTimings::default();
7121        std::thread::scope(|scope| {
7122            let (command_tx, command_rx) = std::sync::mpsc::sync_channel(2);
7123            let (complete_tx, complete_rx) = std::sync::mpsc::sync_channel(2);
7124            let (prepared_tx, prepared_rx) = std::sync::mpsc::sync_channel(1);
7125            let (submitted_tx, submitted_rx) = std::sync::mpsc::sync_channel(1);
7126            let (finished_tx, _finished_rx) = std::sync::mpsc::sync_channel(1);
7127            let compute_submitter = CpuComputeSubmitter {
7128                senders: Vec::new(),
7129                next_id: 0,
7130            };
7131            let worker_memo = &memo;
7132            let worker_timings = &timings;
7133            let worker = scope.spawn(move || {
7134                run_preparation_worker(
7135                    command_rx,
7136                    complete_tx,
7137                    prepared_tx,
7138                    submitted_tx,
7139                    finished_tx,
7140                    CpuKernelKind::Plain,
7141                    CpuKernelKind::Plain.method(),
7142                    std::ptr::NonNull::<u8>::dangling().as_ptr() as usize,
7143                    1,
7144                    worker_memo,
7145                    #[cfg(target_arch = "x86_64")]
7146                    None,
7147                    worker_timings,
7148                    compute_submitter,
7149                    worker_trace,
7150                );
7151            });
7152            command_tx
7153                .send(PreparationMessage::Begin(PrepareBatch {
7154                    set,
7155                    aligned_len: 64,
7156                    chunk_len: 64,
7157                    layout: None,
7158                }))
7159                .unwrap();
7160            command_tx
7161                .send(PreparationMessage::Input {
7162                    lane: 0,
7163                    coefficients: Vec::new(),
7164                    buffer: TransferBuffer {
7165                        slot: 0,
7166                        bytes: vec![0u8; 64],
7167                    },
7168                    submitted: Some(InputBatch {
7169                        staging_area: 0,
7170                        input_start: 0,
7171                        input_len: 1,
7172                        add: false,
7173                        reason: crate::cpu_repair_controller::BatchSubmitReason::GroupFull,
7174                    }),
7175                })
7176                .unwrap();
7177            drop(command_tx);
7178            assert!(worker.join().is_ok());
7179            assert!(complete_rx.recv().is_err());
7180            assert!(prepared_rx.recv().is_err());
7181            assert!(submitted_rx.recv().is_err());
7182        });
7183        assert!(trace.events().iter().any(|event| matches!(
7184            event,
7185            ControllerExecutionEvent::Failed {
7186                phase: ControllerFailurePhase::Prepare
7187            }
7188        )));
7189    }
7190
7191    #[test]
7192    fn controller_transfer_buffers_are_a_fixed_two_slot_protocol() {
7193        let (command_tx, _command_rx) = std::sync::mpsc::sync_channel(1);
7194        let (complete_tx, complete_rx) = std::sync::mpsc::sync_channel(2);
7195        let (_prepared_tx, prepared_rx) = std::sync::mpsc::sync_channel(1);
7196        let (_submitted_tx, submitted_rx) = std::sync::mpsc::sync_channel(1);
7197        let (_finished_tx, finished_rx) = std::sync::mpsc::sync_channel(1);
7198        let mut preparer = CpuInputPreparer {
7199            command_tx,
7200            complete_rx,
7201            prepared_rx,
7202            submitted_rx,
7203            finished_rx,
7204            transfer_buffers: std::array::from_fn(|_| None),
7205            transfer_buffer_len: 8,
7206            // Present only on wasm (see the field's doc); `None` is the
7207            // can-spawn shape, and this test exercises the transfer-buffer
7208            // protocol alone, which needs no inline workers.
7209            #[cfg(target_family = "wasm")]
7210            inline: None,
7211        };
7212
7213        complete_tx
7214            .send(TransferBuffer {
7215                slot: 0,
7216                bytes: vec![0; 8],
7217            })
7218            .unwrap();
7219        complete_tx
7220            .send(TransferBuffer {
7221                slot: 1,
7222                bytes: vec![0; 8],
7223            })
7224            .unwrap();
7225        preparer.restore_transfer_buffers(None).unwrap();
7226
7227        let first = preparer.take_transfer_buffer(None).unwrap();
7228        let second = preparer.take_transfer_buffer(None).unwrap();
7229        assert_eq!((first.slot, second.slot), (0, 1));
7230        assert!(preparer.transfer_buffers.iter().all(Option::is_none));
7231
7232        preparer.return_transfer_buffer(first).unwrap();
7233        let duplicate = TransferBuffer {
7234            slot: 0,
7235            bytes: vec![0; 8],
7236        };
7237        assert!(preparer.return_transfer_buffer(duplicate).is_err());
7238        assert!(
7239            preparer
7240                .return_transfer_buffer(TransferBuffer {
7241                    slot: 2,
7242                    bytes: vec![0; 8],
7243                })
7244                .is_err()
7245        );
7246        assert!(
7247            preparer
7248                .return_transfer_buffer(TransferBuffer {
7249                    slot: 1,
7250                    bytes: vec![0; 7],
7251                })
7252                .is_err()
7253        );
7254
7255        preparer.return_transfer_buffer(second).unwrap();
7256        assert!(preparer.transfer_buffers.iter().all(Option::is_some));
7257    }
7258
7259    #[test]
7260    fn controller_output_area_is_aligned_and_stable() {
7261        let mut area = AlignedOutputArea::new(3, 65_537);
7262        let first = area.base();
7263        let second = area.base();
7264        assert_eq!(first, second);
7265        assert_eq!(first % 64, 0);
7266        assert!(area.cells.len() * std::mem::size_of::<StagingCell>() >= 3 * 65_537);
7267    }
7268
7269    #[test]
7270    fn packed_checksum_is_linear_and_rejects_mutation() {
7271        const DATA_LEN: usize = 96;
7272        const BLOCK_LEN: usize = 32;
7273        let mut left = vec![0u8; DATA_LEN + BLOCK_LEN];
7274        let mut right = vec![0u8; DATA_LEN + BLOCK_LEN];
7275        for (index, byte) in left[..DATA_LEN].iter_mut().enumerate() {
7276            *byte = (index as u8).wrapping_mul(17).wrapping_add(3);
7277        }
7278        for (index, byte) in right[..DATA_LEN].iter_mut().enumerate() {
7279            *byte = (index as u8).wrapping_mul(29).wrapping_add(11);
7280        }
7281        write_packed_checksum(&mut left, DATA_LEN, BLOCK_LEN, BLOCK_LEN);
7282        write_packed_checksum(&mut right, DATA_LEN, BLOCK_LEN, BLOCK_LEN);
7283
7284        let mut combined: Vec<u8> = left
7285            .iter()
7286            .zip(&right)
7287            .map(|(left, right)| left ^ right)
7288            .collect();
7289        assert!(packed_checksum_matches(
7290            &combined,
7291            BLOCK_LEN * 3,
7292            BLOCK_LEN,
7293            BLOCK_LEN
7294        ));
7295
7296        combined[41] ^= 0x80;
7297        assert!(!packed_checksum_matches(
7298            &combined,
7299            BLOCK_LEN * 3,
7300            BLOCK_LEN,
7301            BLOCK_LEN
7302        ));
7303    }
7304
7305    /// The packed checksum feeds verification, so the accelerated kernel must
7306    /// be byte-identical to the lane-strided form it replaced — not merely
7307    /// "also a valid checksum". This holds the current implementation against
7308    /// a literal transcription of the original algorithm across every width
7309    /// and block shape the method contracts produce.
7310    #[test]
7311    fn packed_checksum_matches_original_scalar_algorithm() {
7312        /// Verbatim the pre-optimization kernel.
7313        fn reference(checksum: &mut [u8], block: &[u8]) {
7314            let width = checksum.len();
7315            for lane in (0..width).step_by(2) {
7316                let mut folded = 0u16;
7317                for region in block.chunks_exact(width) {
7318                    folded ^= u16::from_le_bytes([region[lane], region[lane + 1]]);
7319                }
7320                let previous = u16::from_le_bytes([checksum[lane], checksum[lane + 1]]);
7321                checksum[lane..lane + 2]
7322                    .copy_from_slice(&(gf16_mul2(previous) ^ folded).to_le_bytes());
7323            }
7324        }
7325
7326        // (checksum_width, block_len) pairs: Plain (2,2), Folded (32,32),
7327        // NeonPacked (16,32) — the aarch64 production shape, and ParPar's own
7328        // `sizeof(uint8x16_t)` over `sizeof(uint8x16x2_t)` — XOR-JIT AVX2
7329        // (2,32) and AVX-512 (4,64), plus widths that exercise the 8-byte word
7330        // loop's tail.
7331        let shapes = [
7332            (2usize, 2usize),
7333            (2, 32),
7334            (4, 64),
7335            (6, 12),
7336            (16, 32),
7337            (16, 128),
7338            (32, 32),
7339            (32, 96),
7340            (64, 64),
7341            (64, 256),
7342        ];
7343        // The NEON port must clear the same bar. Counted so a port that
7344        // silently declined every shape cannot pass vacuously.
7345        #[cfg(target_arch = "aarch64")]
7346        let mut neon_shapes_covered = 0usize;
7347
7348        for (width, block_len) in shapes {
7349            for blocks in [1usize, 2, 7] {
7350                let data_len = block_len * blocks;
7351                let data: Vec<u8> = (0..data_len)
7352                    .map(|i| ((i * 37 + width * 5 + 1) % 256) as u8)
7353                    .collect();
7354
7355                let mut got = vec![0u8; width];
7356                let mut want = vec![0u8; width];
7357                for block in data.chunks_exact(block_len) {
7358                    update_packed_checksum(&mut got, block);
7359                    reference(&mut want, block);
7360                }
7361                assert_eq!(
7362                    got, want,
7363                    "checksum differs width={width} block_len={block_len} blocks={blocks}"
7364                );
7365
7366                #[cfg(target_arch = "aarch64")]
7367                {
7368                    // Block-at-a-time, matching `gf16_checksum_block_neon`'s
7369                    // own load/mul2/xor/store contract.
7370                    let mut neon = vec![0u8; width];
7371                    let mut neon_ran = true;
7372                    for block in data.chunks_exact(block_len) {
7373                        neon_ran &= parpar_neon_checksum::update_block(&mut neon, block);
7374                    }
7375                    // Whole-sweep form, with the accumulator held in registers
7376                    // across blocks — the shape production actually calls.
7377                    let swept = parpar_neon_checksum::fold(&data, block_len, width);
7378
7379                    if width % 16 == 0 {
7380                        assert!(
7381                            neon_ran && swept.is_some(),
7382                            "NEON port declined a 16-byte-multiple shape \
7383                             width={width} block_len={block_len}"
7384                        );
7385                        assert_eq!(
7386                            neon, want,
7387                            "NEON per-block differs width={width} \
7388                             block_len={block_len} blocks={blocks}"
7389                        );
7390                        assert_eq!(
7391                            &swept.expect("swept")[..width],
7392                            &want[..],
7393                            "NEON sweep differs width={width} \
7394                             block_len={block_len} blocks={blocks}"
7395                        );
7396                        neon_shapes_covered += 1;
7397                    } else {
7398                        // Sub-vector widths have no oracle NEON form; the port
7399                        // must decline rather than invent one.
7400                        assert!(
7401                            !neon_ran && swept.is_none(),
7402                            "NEON port accepted a shape ParPar has no form for: \
7403                             width={width} block_len={block_len}"
7404                        );
7405                    }
7406                }
7407            }
7408        }
7409
7410        #[cfg(target_arch = "aarch64")]
7411        assert_eq!(
7412            neon_shapes_covered, 18,
7413            "NEON coverage went vacuous: expected all six 16-byte-multiple \
7414             shapes across three block counts"
7415        );
7416        #[cfg(not(target_arch = "aarch64"))]
7417        eprintln!("SKIP: NEON checksum arm not covered — target is not aarch64");
7418    }
7419
7420    /// The NEON `mul2` idiom is a different construction from the scalar one
7421    /// (arithmetic-shift mask instead of a branch), so it is pinned over the
7422    /// whole 16-bit domain, including the 0x8000 reduction boundary.
7423    #[cfg(target_arch = "aarch64")]
7424    #[test]
7425    fn parpar_neon_vec_mul2_matches_scalar_exhaustively() {
7426        // Drive it through the public block form: a width-16 accumulator XORed
7427        // with a zero block is exactly one `gf16_vec_mul2_neon` application.
7428        let zero_block = [0u8; 16];
7429        for base in (0..=0xFFFFu32).step_by(8) {
7430            let lanes: [u16; 8] = std::array::from_fn(|i| (base + i as u32) as u16);
7431            let mut checksum = [0u8; 16];
7432            for (i, lane) in lanes.iter().enumerate() {
7433                checksum[i * 2..i * 2 + 2].copy_from_slice(&lane.to_le_bytes());
7434            }
7435            assert!(parpar_neon_checksum::update_block(
7436                &mut checksum,
7437                &zero_block
7438            ));
7439            for (i, lane) in lanes.iter().enumerate() {
7440                assert_eq!(
7441                    &checksum[i * 2..i * 2 + 2],
7442                    &gf16_mul2(*lane).to_le_bytes(),
7443                    "lane {lane:#06x} differs"
7444                );
7445            }
7446        }
7447    }
7448
7449    /// The whole-sweep NEON arm must agree byte-for-byte with the portable arm
7450    /// it displaces, over every plane count (1-4) and a zero-block edge case.
7451    /// Both arms are called directly, so this holds regardless of which one the
7452    /// `WEAVER_PAR2_CKSUM_NEON` gate happens to select.
7453    #[cfg(target_arch = "aarch64")]
7454    #[test]
7455    fn parpar_neon_fold_agrees_with_portable_arm() {
7456        // `(16, 32)` is the aarch64 production shape — `NeonPacked`, and
7457        // ParPar's own `sizeof(uint8x16_t)` checksum over a
7458        // `sizeof(uint8x16x2_t)` block.
7459        for (width, block_len) in [
7460            (16usize, 16usize),
7461            (16, 32),
7462            (16, 128),
7463            (32, 32),
7464            (32, 96),
7465            (48, 96),
7466            (64, 256),
7467        ] {
7468            for blocks in [0usize, 1, 3, 9] {
7469                let data: Vec<u8> = (0..block_len * blocks)
7470                    .map(|i| ((i * 131 + width * 7 + 5) % 256) as u8)
7471                    .collect();
7472                let neon = parpar_neon_checksum::fold(&data, block_len, width)
7473                    .expect("NEON arm handles 16-byte-multiple widths");
7474                let mut portable = [0u8; 64];
7475                for block in data.chunks_exact(block_len) {
7476                    update_packed_checksum(&mut portable[..width], block);
7477                }
7478                assert_eq!(
7479                    &neon[..width],
7480                    &portable[..width],
7481                    "width={width} block_len={block_len} blocks={blocks}"
7482                );
7483            }
7484        }
7485    }
7486
7487    /// The aarch64 staging contract is ParPar's, and it is what production
7488    /// selects. Both halves are pinned: a regression in the numbers *or* a
7489    /// silent return to `Plain` would put `parpar_neon_checksum` back to being
7490    /// dead code, which is the failure this whole arm exists to prevent.
7491    #[cfg(target_arch = "aarch64")]
7492    #[test]
7493    fn neon_packed_is_the_oracle_contract_and_reaches_the_checksum_port() {
7494        let method = CpuKernelKind::NeonPacked.method();
7495        // `GF_PREPARE_PACKED_FUNCS(gf16_clmul, _neon, sizeof(uint8x16x2_t),
7496        // gf16_prepare_block_neon, …, sizeof(uint8x16_t))` —
7497        // parpar/gf16/gf16_clmul_neon.c:27.
7498        assert_eq!(method.stride, 32, "oracle blockLen is sizeof(uint8x16x2_t)");
7499        assert_eq!(
7500            method.checksum_width, 16,
7501            "oracle checksum width is sizeof(uint8x16_t)"
7502        );
7503        assert_eq!(
7504            method.checksum_width / 16,
7505            1,
7506            "PLANES == 1 is gf16_checksum_block_neon verbatim, not a widened form"
7507        );
7508
7509        // Identity staging: the batch shape stays exactly `Plain`'s, because
7510        // the oracle's prepare is a plain copy. Only the granularity moves.
7511        let plain = CpuKernelKind::Plain.method();
7512        assert_eq!(method.ideal_input_multiple, plain.ideal_input_multiple);
7513        assert_eq!(method.staging_multiple, plain.staging_multiple);
7514        assert_eq!(method.alignment, plain.alignment);
7515        assert_eq!(method.ideal_chunk_size, plain.ideal_chunk_size);
7516
7517        // The contract must land on a shape the NEON checksum port accepts —
7518        // this is the reachability that `Plain` never had.
7519        assert!(
7520            parpar_neon_checksum::fold(&[0u8; 128], method.stride, method.checksum_width).is_some(),
7521            "NeonPacked must reach the NEON checksum port"
7522        );
7523        assert!(
7524            parpar_neon_checksum::fold(&[0u8; 128], plain.stride, plain.checksum_width).is_none(),
7525            "Plain's 2-byte contract has no oracle NEON form — that was the gap"
7526        );
7527
7528        // And it must be the default selection.
7529        if std::env::var_os("WEAVER_PAR2_NEON_PACKED").is_none() {
7530            assert!(
7531                neon_packed_enabled(),
7532                "NeonPacked must be default-on when the pin is unset"
7533            );
7534        }
7535    }
7536
7537    /// The four-lane multiply-by-two must agree with the scalar one on every
7538    /// 16-bit value, including the 0x8000 reduction boundary.
7539    #[test]
7540    fn gf16_mul2_x4_matches_scalar_exhaustively() {
7541        for base in (0..=0xFFFFu32).step_by(4) {
7542            let lanes: [u16; 4] = [
7543                base as u16,
7544                (base + 1) as u16,
7545                (base + 2) as u16,
7546                (base + 3) as u16,
7547            ];
7548            let mut packed = [0u8; 8];
7549            for (i, lane) in lanes.iter().enumerate() {
7550                packed[i * 2..i * 2 + 2].copy_from_slice(&lane.to_le_bytes());
7551            }
7552            let got = gf16_mul2_x4(u64::from_le_bytes(packed)).to_le_bytes();
7553            for (i, lane) in lanes.iter().enumerate() {
7554                let want = gf16_mul2(*lane).to_le_bytes();
7555                assert_eq!(&got[i * 2..i * 2 + 2], &want, "lane {lane:#06x} differs");
7556            }
7557        }
7558    }
7559
7560    #[test]
7561    fn repair_with_file_backed_recovery_streaming_succeeds() {
7562        let slice_size = 128u64;
7563        let file_data: Vec<u8> = (0..384u32).map(|i| ((i * 9 + 17) % 256) as u8).collect();
7564        let (mut par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 3);
7565        let _spill_dir = spill_recovery_slices_to_disk(&mut par2_set);
7566
7567        let mut damaged = file_data.clone();
7568        for item in damaged.iter_mut().take(128) {
7569            *item = 0;
7570        }
7571        for item in damaged.iter_mut().take(384).skip(256) {
7572            *item = 0;
7573        }
7574
7575        let mut access = MemoryFileAccess::new();
7576        access.add_file(file_id, damaged);
7577
7578        let result = verify::verify_all(&par2_set, &access);
7579        let plan = plan_repair(&par2_set, &result).unwrap();
7580
7581        execute_repair_with_options(
7582            &plan,
7583            &par2_set,
7584            &mut access,
7585            &RepairOptions {
7586                memory_limit: Some(constrained_memory_limit_for_test(&plan)),
7587                ..RepairOptions::default()
7588            },
7589        )
7590        .unwrap();
7591
7592        let repaired = access.read_file(&file_id).unwrap();
7593        assert_eq!(repaired, file_data);
7594    }
7595
7596    fn synthetic_plan(missing_slices: usize, slice_size: u64) -> RepairPlan {
7597        RepairPlan {
7598            missing_slices: (0..missing_slices)
7599                .map(|i| (FileId::from_bytes([i as u8; 16]), i as u32))
7600                .collect(),
7601            missing_global_indices: (0..missing_slices).collect(),
7602            available_input_global_indices: Vec::new(),
7603            recovery_exponents: (0..missing_slices as u32).collect(),
7604            decode_matrix: matrix::Matrix {
7605                rows: missing_slices,
7606                cols: missing_slices,
7607                data: vec![1; missing_slices.saturating_mul(missing_slices)],
7608            },
7609            input_factors: matrix::Matrix {
7610                rows: missing_slices,
7611                cols: missing_slices,
7612                data: vec![1; missing_slices.saturating_mul(missing_slices)],
7613            },
7614            slice_size,
7615            constants: vec![1; missing_slices],
7616            total_input_slices: missing_slices,
7617            global_to_file: (0..missing_slices)
7618                .map(|i| (FileId::from_bytes([i as u8; 16]), i as u32))
7619                .collect(),
7620        }
7621    }
7622
7623    fn minimum_controller_bytes_for_test(plan: &RepairPlan, kernel: CpuKernelKind) -> usize {
7624        let method = kernel.method();
7625        cpu_controller_plan(
7626            2,
7627            plan.missing_slices.len(),
7628            rayon::current_num_threads().max(1),
7629            method,
7630            method.staging_width(),
7631        )
7632        .buffer_accounting()
7633        .total_bytes
7634    }
7635
7636    fn constrained_memory_limit_for_test(plan: &RepairPlan) -> usize {
7637        let required = minimum_controller_bytes_for_test(plan, CpuKernelKind::Plain).max(
7638            minimum_controller_bytes_for_test(plan, CpuKernelKind::Folded),
7639        );
7640        #[cfg(target_arch = "x86_64")]
7641        let mut required = required;
7642        #[cfg(target_arch = "x86_64")]
7643        if let Some(width) = reedsolomon_rs::xor_jit::JitWidth::detect() {
7644            let kernel = CpuKernelKind::XorJit(width);
7645            let controller_bytes = minimum_controller_bytes_for_test(plan, kernel);
7646            let memo = JitMemo::new(
7647                width,
7648                kernel.method(),
7649                plan.missing_slices.len(),
7650                &plan.input_factors.data,
7651                0,
7652                usize::MAX,
7653            )
7654            .expect("detected XOR-JIT method has bounded controller accounting");
7655            required = required.max(controller_bytes.saturating_add(memo.reserved_bytes()));
7656        }
7657        required
7658    }
7659
7660    #[test]
7661    fn controller_budget_below_physical_minimum_is_rejected_without_mutation() {
7662        let plan = synthetic_plan(1, 64);
7663        let kernel = CpuKernelKind::Plain;
7664        let method = kernel.method();
7665        let minimum = minimum_controller_bytes_for_test(&plan, kernel);
7666        assert!(minimum > 0);
7667        let error = controller_execution_parameters(
7668            &plan,
7669            &RepairOptions {
7670                memory_limit: Some(minimum - 1),
7671                ..RepairOptions::default()
7672            },
7673            method,
7674            method.staging_width(),
7675            0,
7676            rayon::current_num_threads().max(1),
7677        )
7678        .unwrap_err();
7679        assert!(matches!(error, Par2Error::ResourceLimitExceeded { .. }));
7680    }
7681
7682    #[test]
7683    fn controller_parameters_shrink_chunks_to_a_tight_budget() {
7684        let plan = synthetic_plan(450, 1024 * 1024);
7685        let kernel = CpuKernelKind::Plain;
7686        let method = kernel.method();
7687        let (chunk_words, budget, _) = controller_execution_parameters(
7688            &plan,
7689            &RepairOptions {
7690                memory_limit: Some(50 * 1024 * 1024),
7691                ..RepairOptions::default()
7692            },
7693            method,
7694            method.staging_width(),
7695            0,
7696            4,
7697        )
7698        .unwrap();
7699        assert_eq!(budget, 50 * 1024 * 1024);
7700        assert!(chunk_words < plan.slice_size as usize / 2);
7701    }
7702
7703    #[test]
7704    fn cpu_controller_keeps_kernel_grouping_for_small_source_sets() {
7705        let method = CpuKernelKind::Plain.method();
7706        let expected_grouping = method.input_grouping();
7707        for sources in [1, 2, 3, 4, 5, 23, 24] {
7708            let controller = cpu_controller_plan(4096, 2, 4, method, expected_grouping);
7709            assert_eq!(controller.input_grouping(), expected_grouping);
7710            assert_eq!(
7711                crate::cpu_repair_controller::ControllerLifecycle::simulate(
7712                    sources,
7713                    controller.input_grouping(),
7714                )
7715                .batches
7716                .iter()
7717                .map(|batch| batch.input_len)
7718                .sum::<usize>(),
7719                sources
7720            );
7721            let set = StreamBatchSet::new(
7722                controller.layout().aligned_len,
7723                controller.input_grouping(),
7724                controller.input_grouping(),
7725                2,
7726                false,
7727                false,
7728                false,
7729            );
7730            assert_eq!(set.bufs.len(), expected_grouping);
7731        }
7732    }
7733
7734    #[test]
7735    fn gpu_staging_keeps_plain_sources_for_packed_cpu_fallback() {
7736        let set = StreamBatchSet::new(256, 6, 6, 2, false, true, true);
7737        assert_eq!(set.bufs.len(), 6);
7738        assert!(!set.packed.is_empty());
7739    }
7740
7741    fn controller_bytes_for_test(
7742        chunk_words: usize,
7743        output_count: usize,
7744        workers: usize,
7745        method: CpuMethodContract,
7746    ) -> usize {
7747        cpu_controller_plan(
7748            chunk_words.saturating_mul(2),
7749            output_count,
7750            workers,
7751            method,
7752            method.staging_width(),
7753        )
7754        .buffer_accounting()
7755        .total_bytes
7756    }
7757
7758    #[test]
7759    fn chunk_sizing_takes_the_largest_chunk_the_budget_holds() {
7760        let method = CpuKernelKind::Plain.method();
7761        let word_count = 64 * 1024 / 2;
7762        let workers = 12;
7763        let budget = 64 * 1024 * 1024;
7764        for output_count in [1usize, 512, 3000] {
7765            let chunk = largest_fitting_chunk_words(
7766                word_count,
7767                output_count,
7768                workers,
7769                method,
7770                method.staging_width(),
7771                budget,
7772            )
7773            .expect("a 64 MiB budget holds at least one word");
7774            assert!(controller_bytes_for_test(chunk, output_count, workers, method) <= budget);
7775            assert!(
7776                chunk == word_count
7777                    || controller_bytes_for_test(chunk + 1, output_count, workers, method) > budget,
7778                "chunk {chunk} for {output_count} outputs is not the largest fit"
7779            );
7780            // The old sizing halved the slice until it fit; that answer can
7781            // never beat the exact one.
7782            let mut halved = word_count;
7783            while controller_bytes_for_test(halved, output_count, workers, method) > budget {
7784                halved = halved.div_ceil(2);
7785            }
7786            assert!(
7787                halved <= chunk,
7788                "halving beat the exact fit at {output_count}"
7789            );
7790        }
7791    }
7792
7793    #[cfg(target_arch = "x86_64")]
7794    fn xorjit_selection_for_test(output_count: usize, budget: usize) -> XorJitSelection {
7795        let width = reedsolomon_rs::xor_jit::JitWidth::Avx2;
7796        XorJitSelection {
7797            width,
7798            jit_method: CpuKernelKind::XorJit(width).method(),
7799            baseline_method: CpuKernelKind::Folded.method(),
7800            output_count,
7801            word_count: 64 * 1024 / 2,
7802            workers: 12,
7803            budget,
7804        }
7805    }
7806
7807    #[cfg(target_arch = "x86_64")]
7808    fn active_arena_reservation_for_test(selection: XorJitSelection) -> usize {
7809        reedsolomon_rs::xor_jit::packed::PackedJitBatch::active_arena_upper_bound(
7810            selection.width,
7811            selection.output_count,
7812            selection.jit_method.input_grouping(),
7813        )
7814        .expect("the AVX2 arena bound is finite")
7815            * 2
7816    }
7817
7818    #[cfg(target_arch = "x86_64")]
7819    #[test]
7820    fn xorjit_declines_when_its_active_arenas_cost_a_repair_chunk() {
7821        // The arena mode regenerates code every pass, so a chunk it shrinks is
7822        // paid for twice over. 64 MiB is the old default, 128 MiB the current
7823        // one; neither buys these shapes their chunk back.
7824        for budget in [64 * 1024 * 1024, DEFAULT_REPAIR_MEMORY_LIMIT] {
7825            for output_count in [2048usize, 3000] {
7826                let selection = xorjit_selection_for_test(output_count, budget);
7827                let decision =
7828                    selection.budget_decision(active_arena_reservation_for_test(selection));
7829                assert!(
7830                    !decision.accepted(),
7831                    "{output_count} missing slices at {budget} bytes: {decision:?}"
7832                );
7833            }
7834        }
7835    }
7836
7837    #[cfg(target_arch = "x86_64")]
7838    #[test]
7839    fn xorjit_is_selected_when_its_reservation_costs_no_chunk() {
7840        let selection = xorjit_selection_for_test(4, DEFAULT_REPAIR_MEMORY_LIMIT);
7841        let decision = selection.budget_decision(active_arena_reservation_for_test(selection));
7842        assert!(decision.accepted(), "{decision:?}");
7843    }
7844
7845    #[cfg(target_arch = "x86_64")]
7846    #[test]
7847    fn xorjit_capacity_shortfall_selects_the_non_jit_kernel() {
7848        // Past 5462 outputs the arena bound saturates at one body per distinct
7849        // factor, about 80 MiB an arena; two of them cannot fit 128 MiB.
7850        let mut selection = xorjit_selection_for_test(8192, DEFAULT_REPAIR_MEMORY_LIMIT);
7851        assert!(active_arena_reservation_for_test(selection) > selection.budget);
7852        // Capacity is what this test exercises; the strict-W^X precondition is
7853        // a host capability `JitMemo::new` checks first, so pin it on.
7854        selection.jit_method.strict_wx_available = true;
7855        let selected = selection
7856            .select_memo(&[1u16, 2, 3])
7857            .expect("a capacity shortfall is a selection outcome, not an error");
7858        assert!(selected.is_none());
7859    }
7860
7861    /// The codebook mode is admitted only within `budget - full_controller`,
7862    /// so wherever it fits it leaves the whole slice as the chunk. The same
7863    /// rule accepts it that declines the arenas above.
7864    #[cfg(target_arch = "x86_64")]
7865    #[test]
7866    fn xorjit_codebook_sized_reservation_keeps_the_whole_slice() {
7867        let selection = xorjit_selection_for_test(3000, 512 * 1024 * 1024);
7868        let full_controller_bytes = controller_bytes_for_test(
7869            selection.word_count,
7870            selection.output_count,
7871            selection.workers,
7872            selection.jit_method,
7873        );
7874        let decision =
7875            selection.budget_decision(selection.budget.saturating_sub(full_controller_bytes));
7876        assert_eq!(decision.jit_chunk_words, Some(selection.word_count));
7877        assert!(decision.accepted(), "{decision:?}");
7878    }
7879
7880    #[cfg(target_arch = "x86_64")]
7881    #[test]
7882    fn xorjit_selects_the_codebook_at_a_large_limit() {
7883        if !reedsolomon_rs::xor_jit::strict_wx_available() {
7884            // No strict W^X here (a translated process, for one): the codebook
7885            // cannot be compiled, and its selection is not what fails.
7886            return;
7887        }
7888        let selection = xorjit_selection_for_test(3000, 512 * 1024 * 1024);
7889        let factors = (1u16..=3000).collect::<Vec<_>>();
7890        let memo = selection
7891            .select_memo(&factors)
7892            .expect("a codebook that fits is not a failure")
7893            .expect("512 MiB holds the codebook beside a full-slice chunk");
7894        assert_eq!(memo.storage_kind(), "codebook");
7895    }
7896
7897    #[cfg(target_arch = "x86_64")]
7898    #[test]
7899    fn avx2_jit_memo_accounts_two_active_batch_arenas() {
7900        let method = CpuMethodContract {
7901            stride: 32,
7902            alignment: 64,
7903            ideal_input_multiple: 1,
7904            staging_multiple: 1,
7905            ideal_chunk_size: XORJIT_AVX2_IDEAL_CHUNK_BYTES,
7906            checksum_width: 2,
7907            prefetch: CpuPrefetch {
7908                inputs_per_invoke: 1,
7909                input_distance_shift: 1,
7910                output: true,
7911            },
7912            strict_wx_available: true,
7913        };
7914        let area = reedsolomon_rs::xor_jit::packed::PackedJitBatch::active_arena_upper_bound(
7915            reedsolomon_rs::xor_jit::JitWidth::Avx2,
7916            1,
7917            method.input_grouping(),
7918        )
7919        .unwrap();
7920        let required = area * 2;
7921        let memo = JitMemo::new(
7922            reedsolomon_rs::xor_jit::JitWidth::Avx2,
7923            method,
7924            1,
7925            &[1],
7926            0,
7927            required,
7928        )
7929        .unwrap();
7930        assert_eq!(memo.reserved_bytes(), required);
7931        assert!(
7932            JitMemo::new(
7933                reedsolomon_rs::xor_jit::JitWidth::Avx2,
7934                method,
7935                1,
7936                &[1],
7937                0,
7938                required - 1,
7939            )
7940            .is_err()
7941        );
7942    }
7943
7944    #[cfg(target_arch = "x86_64")]
7945    #[test]
7946    fn avx2_jit_memo_uses_codebook_only_with_full_chunk_headroom() {
7947        let method = CpuKernelKind::XorJit(reedsolomon_rs::xor_jit::JitWidth::Avx2).method();
7948        let factors = [1u16, 2, 3, 2, 1];
7949        let measured =
7950            reedsolomon_rs::xor_jit::packed::Avx2Codebook::build(&factors, usize::MAX).unwrap();
7951        let codebook_limit = measured.build_peak_bytes();
7952        let retained_bytes = measured.retained_bytes();
7953        drop(measured);
7954
7955        let memo = JitMemo::new(
7956            reedsolomon_rs::xor_jit::JitWidth::Avx2,
7957            method,
7958            1,
7959            &factors,
7960            codebook_limit,
7961            usize::MAX,
7962        )
7963        .unwrap();
7964        assert!(matches!(
7965            &memo.storage,
7966            JitDispatchStorage::RepairCodebook(_)
7967        ));
7968        assert_eq!(memo.reserved_bytes(), retained_bytes);
7969    }
7970
7971    #[test]
7972    fn controller_parameters_use_a_full_slice_when_budget_allows() {
7973        let plan = synthetic_plan(8, 64 * 1024);
7974        let kernel = CpuKernelKind::Plain;
7975        let method = kernel.method();
7976        let (chunk_words, budget, _) = controller_execution_parameters(
7977            &plan,
7978            &RepairOptions {
7979                memory_limit: Some(16 * 1024 * 1024),
7980                ..RepairOptions::default()
7981            },
7982            method,
7983            method.staging_width(),
7984            0,
7985            4,
7986        )
7987        .unwrap();
7988        assert_eq!(budget, 16 * 1024 * 1024);
7989        assert_eq!(chunk_words, plan.slice_size as usize / 2);
7990    }
7991
7992    // ── Repair-solver seam (RFC 123 WP2.5) ─────────────────────────────────
7993
7994    /// Assemble the in-memory sources for a plan (available inputs then recovery
7995    /// blocks) exactly as `run_in_memory_repair` does, drawing the available
7996    /// slices from the known-good padded original.
7997    fn seam_sources(
7998        plan: &RepairPlan,
7999        par2_set: &Par2FileSet,
8000        padded_original: &[u8],
8001        slice_size: usize,
8002    ) -> Vec<Vec<u8>> {
8003        let mut sources = Vec::new();
8004        for &global_idx in &plan.available_input_global_indices {
8005            let (_file_id, local) = plan.global_to_file[global_idx];
8006            let start = local as usize * slice_size;
8007            sources.push(padded_original[start..start + slice_size].to_vec());
8008        }
8009        for &exp in &plan.recovery_exponents {
8010            let mut data = par2_set.recovery_slices[&exp].data.to_vec().unwrap();
8011            data.resize(slice_size, 0);
8012            sources.push(data);
8013        }
8014        sources
8015    }
8016
8017    /// Naive per-word serial GF(2^16) reconstruction used as an independent
8018    /// reference for every parallel controller path.
8019    fn serial_reconstruct(
8020        input_factors: &matrix::Matrix,
8021        sources: &[Vec<u8>],
8022        word_count: usize,
8023    ) -> Vec<Vec<u8>> {
8024        (0..input_factors.rows)
8025            .map(|j| {
8026                let mut out = vec![0u8; word_count * 2];
8027                for (s, src) in sources.iter().enumerate() {
8028                    let factor = input_factors.get(j, s);
8029                    if factor == 0 {
8030                        continue;
8031                    }
8032                    for w in 0..word_count {
8033                        let sv = u16::from_le_bytes([src[w * 2], src[w * 2 + 1]]);
8034                        let cur = u16::from_le_bytes([out[w * 2], out[w * 2 + 1]]);
8035                        let nv = gf::add(cur, gf::mul(factor, sv));
8036                        let b = nv.to_le_bytes();
8037                        out[w * 2] = b[0];
8038                        out[w * 2 + 1] = b[1];
8039                    }
8040                }
8041                out
8042            })
8043            .collect()
8044    }
8045
8046    /// The seam's native default reconstruct must equal both an independent
8047    /// serial GF reference and the original bytes — the same property the PoC's
8048    /// `host_side_reconstruct_recovers_original` checks, but through the real
8049    /// `NativeRepairSolver` over a `RepairProblem`.
8050    #[test]
8051    fn seam_native_solver_matches_serial_reference_and_original() {
8052        let slice_size = 128u64;
8053        let ss = slice_size as usize;
8054        let file_data: Vec<u8> = (0..512u32).map(|i| ((i * 7 + 3) % 256) as u8).collect();
8055        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 4);
8056
8057        // Damage slices 1 and 3 (of 4).
8058        let mut damaged = file_data.clone();
8059        damaged[128..256].fill(0);
8060        damaged[384..512].fill(0);
8061        let mut access = MemoryFileAccess::new();
8062        access.add_file(file_id, damaged);
8063
8064        let result = verify::verify_all(&par2_set, &access);
8065        let plan = plan_repair(&par2_set, &result).unwrap();
8066        let n = plan.missing_slices.len();
8067        assert_eq!(n, 2);
8068
8069        let word_count = ss / 2;
8070        let num_slices = file_data.len() / ss;
8071        let mut padded = file_data.clone();
8072        padded.resize(num_slices * ss, 0);
8073        let sources = seam_sources(&plan, &par2_set, &padded, ss);
8074        let expected = serial_reconstruct(&plan.input_factors, &sources, word_count);
8075
8076        let source_refs: Vec<&[u8]> = sources.iter().map(|s| s.as_slice()).collect();
8077        let mut outputs: Vec<Vec<u8>> = vec![vec![0u8; ss]; n];
8078        {
8079            let mut out_refs: Vec<&mut [u8]> =
8080                outputs.iter_mut().map(|o| o.as_mut_slice()).collect();
8081            let mut problem = RepairProblem {
8082                total_inputs: plan.total_input_slices,
8083                word_count,
8084                missing_indices: &plan.missing_global_indices,
8085                available_indices: &plan.available_input_global_indices,
8086                recovery_exponents: &plan.recovery_exponents,
8087                constants: &plan.constants,
8088                sources: &source_refs,
8089                outputs: &mut out_refs,
8090            };
8091            NativeRepairSolver::new(&plan.input_factors, word_count)
8092                .reconstruct(&mut problem)
8093                .unwrap();
8094        }
8095
8096        assert_eq!(
8097            outputs, expected,
8098            "seam reconstruct must match the serial GF reference byte-for-byte"
8099        );
8100        for (j, &(_, local)) in plan.missing_slices.iter().enumerate() {
8101            let start = local as usize * ss;
8102            assert_eq!(
8103                outputs[j],
8104                &padded[start..start + ss],
8105                "missing slice {local} not recovered"
8106            );
8107        }
8108    }
8109
8110    /// A stand-in for Agent P's wasm solver: it ignores the plan's pre-built
8111    /// matrix and instead rebuilds the coefficient matrix from the `RepairProblem`
8112    /// raw spec using ONLY the `reedsolomon-rs` host API, then reconstructs
8113    /// via `mul_acc_region`. Proves the injection path recovers the original.
8114    struct HostStyleSolver;
8115
8116    impl RepairSolver for HostStyleSolver {
8117        fn reconstruct(
8118            &self,
8119            problem: &mut RepairProblem<'_>,
8120        ) -> std::result::Result<(), SolverError> {
8121            let coeffs = reedsolomon_rs::matrix::build_repair_matrix(
8122                problem.available_indices,
8123                problem.missing_indices,
8124                problem.recovery_exponents,
8125                problem.constants,
8126            )
8127            .map_err(|e| SolverError::Singular { bad_row: e.bad_row })?;
8128            let sources = problem.sources;
8129            for (j, out) in problem.outputs.iter_mut().enumerate() {
8130                let out: &mut [u8] = out;
8131                out.fill(0);
8132                for (s, src) in sources.iter().enumerate() {
8133                    reedsolomon_rs::gf_simd::mul_acc_region(coeffs.get(j, s), src, out);
8134                }
8135            }
8136            Ok(())
8137        }
8138    }
8139
8140    /// Native builds reject the quarantined in-memory solver seam before it
8141    /// can mutate repaired output.
8142    #[test]
8143    fn execute_repair_with_solver_is_quarantined_on_native_targets() {
8144        let slice_size = 128u64;
8145        let file_data: Vec<u8> = (0..640u32).map(|i| ((i * 11 + 5) % 256) as u8).collect();
8146        let (par2_set, file_id) = setup_repairable_set(&file_data, slice_size, 3);
8147
8148        // Damage slices 0 and 2 (of 5).
8149        let mut damaged = file_data.clone();
8150        damaged[..128].fill(0);
8151        damaged[256..384].fill(0);
8152        let mut access = MemoryFileAccess::new();
8153        access.add_file(file_id, damaged);
8154
8155        let result = verify::verify_all(&par2_set, &access);
8156        assert_eq!(result.total_missing_blocks, 2);
8157        let plan = plan_repair(&par2_set, &result).unwrap();
8158
8159        let error = execute_repair_with_solver(
8160            &plan,
8161            &par2_set,
8162            &mut access,
8163            &RepairOptions::default(),
8164            &HostStyleSolver,
8165        )
8166        .unwrap_err();
8167
8168        assert!(matches!(error, Par2Error::ReedSolomonError { .. }));
8169        assert_ne!(access.read_file(&file_id).unwrap(), file_data);
8170    }
8171
8172    /// The host-side repair matrix (`reedsolomon-rs`) must be byte-identical
8173    /// to the coefficient matrix par2-rs builds natively, so a host solve
8174    /// equals a native weaver repair.
8175    #[test]
8176    fn reedsolomon_rs_repair_matrix_matches_par2_rs() {
8177        let total = 20usize;
8178        let constants = gf::input_slice_constants(total);
8179        let missing = vec![3usize, 7, 11, 15];
8180        let exps: Vec<u32> = vec![0, 1, 2, 3];
8181        let avail: Vec<usize> = (0..total).filter(|i| !missing.contains(i)).collect();
8182
8183        let (weaver_repair, _decode) =
8184            matrix::build_repair_matrix_with_bad_row(&avail, &missing, &exps, &constants).unwrap();
8185        let host = reedsolomon_rs::matrix::build_repair_matrix(&avail, &missing, &exps, &constants)
8186            .unwrap();
8187
8188        assert_eq!(weaver_repair.rows, host.rows);
8189        assert_eq!(weaver_repair.cols, host.cols);
8190        assert_eq!(
8191            weaver_repair.data, host.data,
8192            "host repair matrix must be byte-identical to par2-rs's"
8193        );
8194    }
8195}