use crate::raptorq::decoder::{DecodeError, InactivationDecoder, ReceivedSymbol};
use crate::types::ObjectId;
use crate::util::DetHasher;
use std::collections::BinaryHeap;
use std::fmt;
pub const MAX_PIVOT_EVENTS: usize = 256;
pub const MAX_RECEIVED_SYMBOLS: usize = 1024;
pub const PROOF_SCHEMA_VERSION: u8 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodeProof {
pub version: u8,
pub config: DecodeConfig,
pub received: ReceivedSummary,
pub peeling: PeelingTrace,
pub elimination: EliminationTrace,
pub outcome: ProofOutcome,
}
impl DecodeProof {
#[must_use]
#[inline]
pub fn builder(config: DecodeConfig) -> DecodeProofBuilder {
DecodeProofBuilder::new(config)
}
#[must_use]
pub fn content_hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = DetHasher::default();
self.version.hash(&mut hasher);
self.config.hash(&mut hasher);
self.received.hash(&mut hasher);
self.peeling.hash(&mut hasher);
self.elimination.hash(&mut hasher);
self.outcome.hash(&mut hasher);
hasher.finish()
}
pub fn replay_and_verify(&self, symbols: &[ReceivedSymbol]) -> Result<(), ReplayError> {
let decoder =
InactivationDecoder::new(self.config.k, self.config.symbol_size, self.config.seed);
let actual =
match decoder.decode_with_proof(symbols, self.config.object_id, self.config.sbn) {
Ok(result) => result.proof,
Err((_err, proof)) => proof,
};
compare_proofs(self, &actual)
}
}
#[inline]
fn recovered_source_hash(source: &[Vec<u8>]) -> u64 {
use std::hash::Hasher;
let mut hasher = DetHasher::default();
hasher.write_u64(0x5251_5052_4f4f_4653);
hasher.write_usize(source.len());
for row in source {
hasher.write_usize(row.len());
hasher.write(row);
}
hasher.finish()
}
#[derive(Default)]
struct ReceivedEsiMultisetHashState {
count: u64,
sum: u64,
sum_products: u64,
mix: u64,
}
impl ReceivedEsiMultisetHashState {
#[inline]
fn observe(&mut self, esi: u32, is_source: bool) {
use std::hash::{Hash, Hasher};
let mut hasher = DetHasher::default();
(esi, is_source).hash(&mut hasher);
let digest = hasher.finish();
self.count = self.count.wrapping_add(1);
self.sum = self.sum.wrapping_add(digest);
self.sum_products = self
.sum_products
.wrapping_add(digest.wrapping_mul(digest | 1));
self.mix = self
.mix
.wrapping_add(digest.rotate_left(17) ^ digest.wrapping_mul(0x9E37_79B9_7F4A_7C15));
}
#[inline]
fn finish(self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = DetHasher::default();
self.count.hash(&mut hasher);
self.sum.hash(&mut hasher);
self.sum_products.hash(&mut hasher);
self.mix.hash(&mut hasher);
hasher.finish()
}
}
#[derive(Debug)]
pub enum ReplayError {
Mismatch {
field: &'static str,
expected: String,
actual: String,
},
SequenceMismatch {
label: &'static str,
index: usize,
expected: String,
actual: String,
},
}
impl fmt::Display for ReplayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Mismatch {
field,
expected,
actual,
} => write!(f, "mismatch for {field}: expected {expected}, got {actual}"),
Self::SequenceMismatch {
label,
index,
expected,
actual,
} => write!(
f,
"sequence mismatch for {label} at index {index}: expected {expected}, got {actual}"
),
}
}
}
impl std::error::Error for ReplayError {}
#[inline]
fn mismatch<T: fmt::Debug>(field: &'static str, expected: T, actual: T) -> ReplayError {
ReplayError::Mismatch {
field,
expected: format!("{expected:?}"),
actual: format!("{actual:?}"),
}
}
#[inline]
fn sequence_mismatch(
label: &'static str,
index: usize,
expected: String,
actual: String,
) -> ReplayError {
ReplayError::SequenceMismatch {
label,
index,
expected,
actual,
}
}
fn compare_prefix<T: PartialEq + fmt::Debug>(
label: &'static str,
expected: &[T],
actual: &[T],
truncated: bool,
) -> Result<(), ReplayError> {
if actual.len() != expected.len() {
let idx = expected.len().min(actual.len());
let (expected_item, actual_item) = if actual.len() < expected.len() {
(
format!("{:?}", expected.get(actual.len())),
"missing".to_string(),
)
} else if truncated {
(
format!("len {}", expected.len()),
format!("len {}", actual.len()),
)
} else {
(
"missing".to_string(),
format!("{:?}", actual.get(expected.len())),
)
};
return Err(sequence_mismatch(label, idx, expected_item, actual_item));
}
for (idx, (exp, act)) in expected.iter().zip(actual.iter()).enumerate() {
if exp != act {
return Err(sequence_mismatch(
label,
idx,
format!("{exp:?}"),
format!("{act:?}"),
));
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn compare_proofs(expected: &DecodeProof, actual: &DecodeProof) -> Result<(), ReplayError> {
if expected.version != actual.version {
return Err(mismatch("version", expected.version, actual.version));
}
if expected.config != actual.config {
return Err(mismatch("config", &expected.config, &actual.config));
}
let exp_recv = &expected.received;
let act_recv = &actual.received;
if exp_recv.total != act_recv.total {
return Err(mismatch("received.total", exp_recv.total, act_recv.total));
}
if exp_recv.source_count != act_recv.source_count {
return Err(mismatch(
"received.source_count",
exp_recv.source_count,
act_recv.source_count,
));
}
if exp_recv.repair_count != act_recv.repair_count {
return Err(mismatch(
"received.repair_count",
exp_recv.repair_count,
act_recv.repair_count,
));
}
if exp_recv.esi_multiset_hash != act_recv.esi_multiset_hash {
return Err(mismatch(
"received.esi_multiset_hash",
exp_recv.esi_multiset_hash,
act_recv.esi_multiset_hash,
));
}
if exp_recv.truncated != act_recv.truncated {
return Err(mismatch(
"received.truncated",
exp_recv.truncated,
act_recv.truncated,
));
}
compare_prefix(
"received.esis",
&exp_recv.esis,
&act_recv.esis,
exp_recv.truncated,
)?;
let exp_peel = &expected.peeling;
let act_peel = &actual.peeling;
if exp_peel.solved != act_peel.solved {
return Err(mismatch("peeling.solved", exp_peel.solved, act_peel.solved));
}
if exp_peel.truncated != act_peel.truncated {
return Err(mismatch(
"peeling.truncated",
exp_peel.truncated,
act_peel.truncated,
));
}
compare_prefix(
"peeling.solved_indices",
&exp_peel.solved_indices,
&act_peel.solved_indices,
exp_peel.truncated,
)?;
let exp_elim = &expected.elimination;
let act_elim = &actual.elimination;
if exp_elim.inactivated != act_elim.inactivated {
return Err(mismatch(
"elimination.inactivated",
exp_elim.inactivated,
act_elim.inactivated,
));
}
if exp_elim.pivots != act_elim.pivots {
return Err(mismatch(
"elimination.pivots",
exp_elim.pivots,
act_elim.pivots,
));
}
if exp_elim.row_ops != act_elim.row_ops {
return Err(mismatch(
"elimination.row_ops",
exp_elim.row_ops,
act_elim.row_ops,
));
}
if exp_elim.inactive_cols_truncated != act_elim.inactive_cols_truncated {
return Err(mismatch(
"elimination.inactive_cols_truncated",
exp_elim.inactive_cols_truncated,
act_elim.inactive_cols_truncated,
));
}
if exp_elim.pivot_events_truncated != act_elim.pivot_events_truncated {
return Err(mismatch(
"elimination.pivot_events_truncated",
exp_elim.pivot_events_truncated,
act_elim.pivot_events_truncated,
));
}
if exp_elim.strategy_transitions_truncated != act_elim.strategy_transitions_truncated {
return Err(mismatch(
"elimination.strategy_transitions_truncated",
exp_elim.strategy_transitions_truncated,
act_elim.strategy_transitions_truncated,
));
}
if exp_elim.strategy != act_elim.strategy {
return Err(mismatch(
"elimination.strategy",
exp_elim.strategy,
act_elim.strategy,
));
}
compare_prefix(
"elimination.inactive_cols",
&exp_elim.inactive_cols,
&act_elim.inactive_cols,
exp_elim.inactive_cols_truncated,
)?;
compare_prefix(
"elimination.pivot_events",
&exp_elim.pivot_events,
&act_elim.pivot_events,
exp_elim.pivot_events_truncated,
)?;
compare_prefix(
"elimination.strategy_transitions",
&exp_elim.strategy_transitions,
&act_elim.strategy_transitions,
exp_elim.strategy_transitions_truncated,
)?;
if expected.outcome != actual.outcome {
return Err(mismatch("outcome", &expected.outcome, &actual.outcome));
}
Ok(())
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct DecodeConfig {
pub object_id: ObjectId,
pub sbn: u8,
pub k: usize,
pub s: usize,
pub h: usize,
pub l: usize,
pub symbol_size: usize,
pub seed: u64,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct ReceivedSummary {
pub total: usize,
pub source_count: usize,
pub repair_count: usize,
pub esi_multiset_hash: u64,
pub esis: Vec<u32>,
pub truncated: bool,
}
impl ReceivedSummary {
#[must_use]
pub fn from_received(symbols: impl Iterator<Item = (u32, bool)>) -> Self {
let mut source_count = 0;
let mut repair_count = 0;
let mut total = 0usize;
let mut esis_heap: BinaryHeap<u32> = BinaryHeap::new();
let mut hash_state = ReceivedEsiMultisetHashState::default();
for (esi, is_source) in symbols {
total += 1;
if is_source {
source_count += 1;
} else {
repair_count += 1;
}
hash_state.observe(esi, is_source);
if esis_heap.len() < MAX_RECEIVED_SYMBOLS {
esis_heap.push(esi);
continue;
}
if let Some(&max) = esis_heap.peek() {
if esi < max {
esis_heap.pop();
esis_heap.push(esi);
}
}
}
let truncated = total > MAX_RECEIVED_SYMBOLS;
let esi_multiset_hash = hash_state.finish();
let mut esis = esis_heap.into_vec();
esis.sort_unstable();
Self {
total,
source_count,
repair_count,
esi_multiset_hash,
esis,
truncated,
}
}
}
#[derive(Debug, Clone, Default, Hash, PartialEq, Eq)]
pub struct PeelingTrace {
pub solved: usize,
pub solved_indices: Vec<usize>,
pub truncated: bool,
}
impl PeelingTrace {
pub fn record_solved(&mut self, col: usize) {
self.solved += 1;
if self.solved_indices.len() < MAX_PIVOT_EVENTS {
self.solved_indices.push(col);
} else {
self.truncated = true;
}
}
}
#[derive(Debug, Clone, Default, Hash, PartialEq, Eq)]
pub struct EliminationTrace {
pub strategy: InactivationStrategy,
pub inactivated: usize,
pub inactive_cols: Vec<usize>,
pub pivots: usize,
pub pivot_events: Vec<PivotEvent>,
pub inactive_cols_truncated: bool,
pub pivot_events_truncated: bool,
pub row_ops: usize,
pub strategy_transitions: Vec<StrategyTransition>,
pub strategy_transitions_truncated: bool,
}
impl EliminationTrace {
pub fn set_strategy(&mut self, strategy: InactivationStrategy) {
self.strategy = strategy;
}
pub fn record_strategy_transition(
&mut self,
from: InactivationStrategy,
to: InactivationStrategy,
reason: &'static str,
) {
if from == to {
self.strategy = to;
return;
}
if self.strategy_transitions.len() < MAX_PIVOT_EVENTS {
self.strategy_transitions
.push(StrategyTransition { from, to, reason });
} else {
self.strategy_transitions_truncated = true;
}
self.strategy = to;
}
pub fn record_inactivation(&mut self, col: usize) {
self.inactivated += 1;
if self.inactive_cols.len() < MAX_PIVOT_EVENTS {
self.inactive_cols.push(col);
} else {
self.inactive_cols_truncated = true;
}
}
pub fn record_pivot(&mut self, col: usize, row: usize) {
self.pivots += 1;
if self.pivot_events.len() < MAX_PIVOT_EVENTS {
self.pivot_events.push(PivotEvent { col, row });
} else {
self.pivot_events_truncated = true;
}
}
pub fn record_row_op(&mut self) {
self.row_ops += 1;
}
}
#[derive(Debug, Clone, Copy, Default, Hash, PartialEq, Eq)]
pub enum InactivationStrategy {
#[default]
AllAtOnce,
HighSupportFirst,
BlockSchurLowRank,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct StrategyTransition {
pub from: InactivationStrategy,
pub to: InactivationStrategy,
pub reason: &'static str,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct PivotEvent {
pub col: usize,
pub row: usize,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum ProofOutcome {
Success {
symbols_recovered: usize,
source_payload_hash: u64,
},
Failure {
reason: FailureReason,
},
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum FailureReason {
InsufficientSymbols {
received: usize,
required: usize,
},
SingularMatrix {
row: usize,
attempted_cols: Vec<usize>,
},
SymbolSizeMismatch {
expected: usize,
actual: usize,
},
SymbolEquationArityMismatch {
esi: u32,
columns: usize,
coefficients: usize,
},
ColumnIndexOutOfRange {
esi: u32,
column: usize,
max_valid: usize,
},
SourceEsiOutOfRange {
esi: u32,
max_valid: usize,
},
InvalidSourceSymbolEquation {
esi: u32,
expected_column: usize,
},
CorruptDecodedOutput {
esi: u32,
byte_index: usize,
expected: u8,
actual: u8,
},
}
impl From<&DecodeError> for FailureReason {
fn from(err: &DecodeError) -> Self {
match err {
DecodeError::InsufficientSymbols { received, required } => Self::InsufficientSymbols {
received: *received,
required: *required,
},
DecodeError::SingularMatrix { row } => Self::SingularMatrix {
row: *row,
attempted_cols: Vec::new(), },
DecodeError::SymbolSizeMismatch { expected, actual } => Self::SymbolSizeMismatch {
expected: *expected,
actual: *actual,
},
DecodeError::SymbolEquationArityMismatch {
esi,
columns,
coefficients,
} => Self::SymbolEquationArityMismatch {
esi: *esi,
columns: *columns,
coefficients: *coefficients,
},
DecodeError::ColumnIndexOutOfRange {
esi,
column,
max_valid,
} => Self::ColumnIndexOutOfRange {
esi: *esi,
column: *column,
max_valid: *max_valid,
},
DecodeError::SourceEsiOutOfRange { esi, max_valid } => Self::SourceEsiOutOfRange {
esi: *esi,
max_valid: *max_valid,
},
DecodeError::InvalidSourceSymbolEquation {
esi,
expected_column,
} => Self::InvalidSourceSymbolEquation {
esi: *esi,
expected_column: *expected_column,
},
DecodeError::CorruptDecodedOutput {
esi,
byte_index,
expected,
actual,
} => Self::CorruptDecodedOutput {
esi: *esi,
byte_index: *byte_index,
expected: *expected,
actual: *actual,
},
}
}
}
#[derive(Debug)]
pub struct DecodeProofBuilder {
config: DecodeConfig,
received: Option<ReceivedSummary>,
peeling: PeelingTrace,
elimination: EliminationTrace,
outcome: Option<ProofOutcome>,
}
impl DecodeProofBuilder {
#[must_use]
pub fn new(config: DecodeConfig) -> Self {
Self {
config,
received: None,
peeling: PeelingTrace::default(),
elimination: EliminationTrace::default(),
outcome: None,
}
}
pub fn set_received(&mut self, received: ReceivedSummary) {
self.received = Some(received);
}
pub fn peeling_mut(&mut self) -> &mut PeelingTrace {
&mut self.peeling
}
pub fn elimination_mut(&mut self) -> &mut EliminationTrace {
&mut self.elimination
}
pub fn set_success(&mut self, recovered_source: &[Vec<u8>]) {
self.outcome = Some(ProofOutcome::Success {
symbols_recovered: recovered_source.len(),
source_payload_hash: recovered_source_hash(recovered_source),
});
}
pub fn set_failure(&mut self, reason: FailureReason) {
self.outcome = Some(ProofOutcome::Failure { reason });
}
#[must_use]
pub fn build(self) -> DecodeProof {
DecodeProof {
version: PROOF_SCHEMA_VERSION,
config: self.config,
received: self.received.expect("received must be set before build"),
peeling: self.peeling,
elimination: self.elimination,
outcome: self.outcome.expect("outcome must be set before build"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::raptorq::decoder::{InactivationDecoder, ReceivedSymbol};
use crate::raptorq::systematic::SystematicEncoder;
use serde_json::json;
fn make_test_config() -> DecodeConfig {
DecodeConfig {
object_id: ObjectId::new(0, 1),
sbn: 0,
k: 10,
s: 3,
h: 2,
l: 15,
symbol_size: 64,
seed: 42,
}
}
fn make_test_recovered(config: &DecodeConfig) -> Vec<Vec<u8>> {
vec![vec![0u8; config.symbol_size]; config.k]
}
fn scrub_failure_reason_for_snapshot_test(reason: &FailureReason) -> serde_json::Value {
match reason {
FailureReason::InsufficientSymbols { received, required } => json!({
"kind": "InsufficientSymbols",
"received": received,
"required": required,
}),
FailureReason::SingularMatrix {
row,
attempted_cols,
} => json!({
"kind": "SingularMatrix",
"row": row,
"attempted_cols": attempted_cols,
}),
FailureReason::SymbolSizeMismatch { expected, actual } => json!({
"kind": "SymbolSizeMismatch",
"expected": expected,
"actual": actual,
}),
FailureReason::SymbolEquationArityMismatch {
esi,
columns,
coefficients,
} => json!({
"kind": "SymbolEquationArityMismatch",
"esi": esi,
"columns": columns,
"coefficients": coefficients,
}),
FailureReason::ColumnIndexOutOfRange {
esi,
column,
max_valid,
} => json!({
"kind": "ColumnIndexOutOfRange",
"esi": esi,
"column": column,
"max_valid": max_valid,
}),
FailureReason::SourceEsiOutOfRange { esi, max_valid } => json!({
"kind": "SourceEsiOutOfRange",
"esi": esi,
"max_valid": max_valid,
}),
FailureReason::InvalidSourceSymbolEquation {
esi,
expected_column,
} => json!({
"kind": "InvalidSourceSymbolEquation",
"esi": esi,
"expected_column": expected_column,
}),
FailureReason::CorruptDecodedOutput {
esi,
byte_index,
expected,
actual,
} => json!({
"kind": "CorruptDecodedOutput",
"esi": esi,
"byte_index": byte_index,
"expected": expected,
"actual": actual,
}),
}
}
fn scrub_decode_proof_for_snapshot_test(proof: &DecodeProof) -> serde_json::Value {
json!({
"version": proof.version,
"content_hash": proof.content_hash(),
"config": {
"object_id": "[object_id]",
"sbn": proof.config.sbn,
"k": proof.config.k,
"s": proof.config.s,
"h": proof.config.h,
"l": proof.config.l,
"symbol_size": proof.config.symbol_size,
"seed": "[seed]",
},
"received": {
"total": proof.received.total,
"source_count": proof.received.source_count,
"repair_count": proof.received.repair_count,
"esi_multiset_hash": proof.received.esi_multiset_hash,
"esis": proof.received.esis,
"truncated": proof.received.truncated,
},
"peeling": {
"solved": proof.peeling.solved,
"solved_indices": proof.peeling.solved_indices,
"truncated": proof.peeling.truncated,
},
"elimination": {
"strategy": format!("{:?}", proof.elimination.strategy),
"inactivated": proof.elimination.inactivated,
"inactive_cols": proof.elimination.inactive_cols,
"inactive_cols_truncated": proof.elimination.inactive_cols_truncated,
"pivots": proof.elimination.pivots,
"pivot_events": proof
.elimination
.pivot_events
.iter()
.map(|event| json!({"col": event.col, "row": event.row}))
.collect::<Vec<_>>(),
"pivot_events_truncated": proof.elimination.pivot_events_truncated,
"row_ops": proof.elimination.row_ops,
"strategy_transitions": proof
.elimination
.strategy_transitions
.iter()
.map(|transition| {
json!({
"from": format!("{:?}", transition.from),
"to": format!("{:?}", transition.to),
"reason": transition.reason,
})
})
.collect::<Vec<_>>(),
"strategy_transitions_truncated": proof.elimination.strategy_transitions_truncated,
},
"outcome": match &proof.outcome {
ProofOutcome::Success {
symbols_recovered,
source_payload_hash,
} => json!({
"kind": "Success",
"symbols_recovered": symbols_recovered,
"source_payload_hash": source_payload_hash,
}),
ProofOutcome::Failure { reason } => json!({
"kind": "Failure",
"reason": scrub_failure_reason_for_snapshot_test(reason),
}),
},
})
}
fn make_success_proof_for_snapshot_test() -> DecodeProof {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary::from_received(
(0..15).map(|esi| (esi, esi < 10)),
));
builder.peeling_mut().record_solved(0);
builder.peeling_mut().record_solved(4);
builder.peeling_mut().record_solved(7);
let elimination = builder.elimination_mut();
elimination.set_strategy(InactivationStrategy::AllAtOnce);
elimination.record_strategy_transition(
InactivationStrategy::AllAtOnce,
InactivationStrategy::HighSupportFirst,
"dense_repair_mix",
);
elimination.record_inactivation(11);
elimination.record_pivot(11, 2);
elimination.record_row_op();
elimination.record_pivot(13, 5);
elimination.record_row_op();
builder.set_success(&recovered);
builder.build()
}
fn make_degraded_singular_proof_for_snapshot_test() -> DecodeProof {
let mut config = make_test_config();
config.seed = 1337;
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary::from_received(
[(0, true), (1, true), (3, true), (10, false), (11, false)].into_iter(),
));
builder.peeling_mut().record_solved(0);
let elimination = builder.elimination_mut();
elimination.set_strategy(InactivationStrategy::HighSupportFirst);
elimination.record_inactivation(8);
elimination.record_inactivation(9);
elimination.record_pivot(8, 1);
elimination.record_row_op();
elimination.record_strategy_transition(
InactivationStrategy::HighSupportFirst,
InactivationStrategy::BlockSchurLowRank,
"rank_drop_detected",
);
builder.set_failure(FailureReason::SingularMatrix {
row: 6,
attempted_cols: vec![8, 9, 12],
});
builder.build()
}
fn make_degraded_insufficient_proof_for_snapshot_test() -> DecodeProof {
let mut config = make_test_config();
config.seed = 7;
let required = config.l;
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary::from_received(
(0..6).map(|esi| (esi, true)),
));
builder.peeling_mut().record_solved(1);
builder.peeling_mut().record_solved(2);
builder
.elimination_mut()
.set_strategy(InactivationStrategy::AllAtOnce);
builder.set_failure(FailureReason::InsufficientSymbols {
received: 6,
required,
});
builder.build()
}
fn make_source_block_payload_for_snapshot_test(
k: usize,
symbol_size: usize,
salt: u8,
) -> Vec<Vec<u8>> {
(0..k)
.map(|i| {
(0..symbol_size)
.map(|j| ((i * 37 + j * 19 + usize::from(salt)) % 256) as u8)
.collect()
})
.collect()
}
fn make_known_good_source_block_proof_for_snapshot_test() -> DecodeProof {
let k = 8;
let symbol_size = 32;
let seed = 2026;
let source = make_source_block_payload_for_snapshot_test(k, symbol_size, 0x21);
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let mut received = decoder.constraint_symbols();
for (esi, data) in source.iter().enumerate() {
received.push(ReceivedSymbol::source(esi as u32, data.clone()));
}
let proof = decoder
.decode_with_proof(&received, ObjectId::new_for_test(2026), 0)
.expect("known-good source block should decode")
.proof;
assert!(
matches!(proof.outcome, ProofOutcome::Success { .. }),
"known-good snapshot scenario must produce a success certificate"
);
proof
}
fn make_known_bad_source_block_proof_for_snapshot_test() -> DecodeProof {
let k = 8;
let symbol_size = 32;
let seed = 2026;
let source = make_source_block_payload_for_snapshot_test(k, symbol_size, 0x4D);
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let mut received = decoder.constraint_symbols();
for (esi, data) in source.iter().enumerate().take(4) {
received.push(ReceivedSymbol::source(esi as u32, data.clone()));
}
let (_err, proof) = decoder
.decode_with_proof(&received, ObjectId::new_for_test(3030), 0)
.expect_err("known-bad source block should fail decode");
assert!(
matches!(proof.outcome, ProofOutcome::Failure { .. }),
"known-bad snapshot scenario must produce a failure certificate"
);
proof
}
#[test]
fn proof_builder_success() {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary {
total: 15,
source_count: 10,
repair_count: 5,
esi_multiset_hash: 123,
esis: (0..15).collect(),
truncated: false,
});
builder.peeling_mut().record_solved(0);
builder.peeling_mut().record_solved(1);
builder.elimination_mut().record_inactivation(2);
builder.elimination_mut().record_pivot(2, 0);
builder.elimination_mut().record_row_op();
builder.set_success(&recovered);
let proof = builder.build();
assert_eq!(proof.version, PROOF_SCHEMA_VERSION);
assert_eq!(proof.peeling.solved, 2);
assert_eq!(proof.elimination.pivots, 1);
assert!(matches!(proof.outcome, ProofOutcome::Success { .. }));
}
#[test]
fn proof_builder_failure() {
let config = make_test_config();
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary {
total: 5,
source_count: 5,
repair_count: 0,
esi_multiset_hash: 456,
esis: (0..5).collect(),
truncated: false,
});
builder.set_failure(FailureReason::InsufficientSymbols {
received: 5,
required: 15,
});
let proof = builder.build();
assert!(matches!(
proof.outcome,
ProofOutcome::Failure {
reason: FailureReason::InsufficientSymbols { .. }
}
));
}
#[test]
fn decode_proof_certificate_scrubbed() {
let success = make_success_proof_for_snapshot_test();
let degraded_singular = make_degraded_singular_proof_for_snapshot_test();
let degraded_insufficient = make_degraded_insufficient_proof_for_snapshot_test();
let source_block_success = make_known_good_source_block_proof_for_snapshot_test();
let source_block_failure = make_known_bad_source_block_proof_for_snapshot_test();
insta::assert_json_snapshot!(
"decode_proof_certificate_scrubbed",
json!({
"success": scrub_decode_proof_for_snapshot_test(&success),
"degraded_singular": scrub_decode_proof_for_snapshot_test(°raded_singular),
"degraded_insufficient": scrub_decode_proof_for_snapshot_test(°raded_insufficient),
"source_block_success": scrub_decode_proof_for_snapshot_test(&source_block_success),
"source_block_failure": scrub_decode_proof_for_snapshot_test(&source_block_failure),
})
);
}
#[test]
fn source_block_decode_proof_hashes_are_stable() {
let success1 = make_known_good_source_block_proof_for_snapshot_test();
let success2 = make_known_good_source_block_proof_for_snapshot_test();
let failure1 = make_known_bad_source_block_proof_for_snapshot_test();
let failure2 = make_known_bad_source_block_proof_for_snapshot_test();
assert_eq!(success1.content_hash(), success2.content_hash());
assert_eq!(failure1.content_hash(), failure2.content_hash());
}
#[test]
fn received_summary_truncation() {
let symbols = (0..2000).map(|i| (i, i < 1000));
let summary = ReceivedSummary::from_received(symbols);
assert_eq!(summary.total, 2000);
assert_eq!(summary.source_count, 1000);
assert_eq!(summary.repair_count, 1000);
assert_eq!(summary.esis.len(), MAX_RECEIVED_SYMBOLS);
assert!(summary.truncated);
}
#[test]
fn received_summary_hash_changes_when_high_esis_change_beyond_preview() {
let total = MAX_RECEIVED_SYMBOLS as u32 + 8;
let original = ReceivedSummary::from_received((0..total).map(|esi| (esi, esi < 8)));
let mutated = ReceivedSummary::from_received(
(0..(total - 1))
.map(|esi| (esi, esi < 8))
.chain(std::iter::once((u32::MAX - 7, false))),
);
assert_eq!(original.total, mutated.total);
assert_eq!(original.source_count, mutated.source_count);
assert_eq!(original.repair_count, mutated.repair_count);
assert_eq!(
original.esis, mutated.esis,
"preview ESIs should stay identical when only higher truncated ESIs differ"
);
assert!(original.truncated);
assert!(mutated.truncated);
assert_ne!(
original.esi_multiset_hash, mutated.esi_multiset_hash,
"full multiset hash must distinguish divergence beyond the preview window"
);
}
#[test]
fn received_summary_hash_is_order_independent_for_same_multiset() {
let ordered = [
(9, false),
(1, true),
(7, false),
(1, true),
(4, false),
(2, true),
];
let permuted = [
(2, true),
(4, false),
(1, true),
(9, false),
(1, true),
(7, false),
];
let ordered_summary = ReceivedSummary::from_received(ordered.into_iter());
let permuted_summary = ReceivedSummary::from_received(permuted.into_iter());
assert_eq!(ordered_summary.total, permuted_summary.total);
assert_eq!(ordered_summary.source_count, permuted_summary.source_count);
assert_eq!(ordered_summary.repair_count, permuted_summary.repair_count);
assert_eq!(ordered_summary.esis, permuted_summary.esis);
assert_eq!(
ordered_summary.esi_multiset_hash, permuted_summary.esi_multiset_hash,
"multiset hash must remain stable across input orderings"
);
}
#[test]
fn content_hash_deterministic() {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut builder1 = DecodeProof::builder(config.clone());
let mut builder2 = DecodeProof::builder(config);
for builder in [&mut builder1, &mut builder2] {
builder.set_received(ReceivedSummary {
total: 15,
source_count: 10,
repair_count: 5,
esi_multiset_hash: 999,
esis: (0..15).collect(),
truncated: false,
});
builder.set_success(&recovered);
}
let proof1 = builder1.build();
let proof2 = builder2.build();
assert_eq!(proof1.content_hash(), proof2.content_hash());
}
#[test]
fn recovered_source_hash_binds_symbol_boundaries() {
let split_symbols = vec![vec![0x10, 0x20], vec![0x30, 0x40]];
let merged_suffix = vec![vec![0x10], vec![0x20, 0x30, 0x40]];
assert_eq!(
split_symbols.concat(),
merged_suffix.concat(),
"test setup should keep the flattened payload identical"
);
assert_ne!(
recovered_source_hash(&split_symbols),
recovered_source_hash(&merged_suffix),
"proof success hash must bind per-symbol boundaries, not just flattened bytes"
);
}
#[test]
fn recovered_source_hash_binds_symbol_order() {
let ordered = vec![vec![0xAA, 0x01], vec![0xBB, 0x02], vec![0xCC, 0x03]];
let reordered = vec![vec![0xCC, 0x03], vec![0xBB, 0x02], vec![0xAA, 0x01]];
assert_ne!(
recovered_source_hash(&ordered),
recovered_source_hash(&reordered),
"proof success hash must distinguish reordered recovered source symbols"
);
}
#[test]
fn replay_verification_roundtrip() {
let k = 8;
let symbol_size = 32;
let seed = 99u64;
let source: Vec<Vec<u8>> = (0..k)
.map(|i| {
(0..symbol_size)
.map(|j| ((i * 53 + j * 19 + 3) % 256) as u8)
.collect()
})
.collect();
let encoder = SystematicEncoder::new(&source, symbol_size, seed).unwrap();
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let l = decoder.params().l;
let mut received = decoder.constraint_symbols();
for (i, data) in source.iter().enumerate() {
received.push(ReceivedSymbol::source(i as u32, data.clone()));
}
for esi in (k as u32)..(l as u32) {
let (cols, coefs) = decoder.repair_equation(esi);
let repair_data = encoder.repair_symbol(esi);
received.push(ReceivedSymbol::repair(esi, cols, coefs, repair_data));
}
let object_id = ObjectId::new_for_test(777);
let proof = decoder
.decode_with_proof(&received, object_id, 0)
.expect("decode should succeed")
.proof;
proof
.replay_and_verify(&received)
.expect("replay verification should succeed");
}
#[test]
fn decode_config_debug_clone_hash_eq() {
let cfg = make_test_config();
let cfg2 = cfg.clone();
assert_eq!(cfg, cfg2);
assert!(format!("{cfg:?}").contains("DecodeConfig"));
}
#[test]
fn received_summary_debug_clone_hash_eq() {
let summary = ReceivedSummary {
total: 10,
source_count: 7,
repair_count: 3,
esi_multiset_hash: 789,
esis: vec![0, 1, 2],
truncated: false,
};
let summary2 = summary.clone();
assert_eq!(summary, summary2);
assert!(format!("{summary:?}").contains("ReceivedSummary"));
}
#[test]
fn received_summary_from_received_empty() {
let summary = ReceivedSummary::from_received(std::iter::empty());
assert_eq!(summary.total, 0);
assert_eq!(summary.source_count, 0);
assert_eq!(summary.repair_count, 0);
assert!(summary.esis.is_empty());
assert!(!summary.truncated);
}
#[test]
fn peeling_trace_debug_clone_default_hash_eq() {
let trace = PeelingTrace::default();
let trace2 = trace.clone();
assert_eq!(trace, trace2);
assert_eq!(trace.solved, 0);
assert!(format!("{trace:?}").contains("PeelingTrace"));
}
#[test]
fn peeling_trace_record_solved() {
let mut trace = PeelingTrace::default();
trace.record_solved(5);
trace.record_solved(10);
assert_eq!(trace.solved, 2);
assert_eq!(trace.solved_indices, vec![5, 10]);
}
#[test]
fn elimination_trace_debug_clone_default_hash_eq() {
let trace = EliminationTrace::default();
let trace2 = trace.clone();
assert_eq!(trace, trace2);
assert!(format!("{trace:?}").contains("EliminationTrace"));
}
#[test]
fn elimination_trace_record_operations() {
let mut trace = EliminationTrace::default();
trace.record_inactivation(3);
trace.record_pivot(3, 0);
trace.record_row_op();
assert_eq!(trace.inactivated, 1);
assert_eq!(trace.pivots, 1);
assert_eq!(trace.row_ops, 1);
assert_eq!(trace.pivot_events.len(), 1);
assert!(!trace.inactive_cols_truncated);
assert!(!trace.pivot_events_truncated);
assert!(!trace.strategy_transitions_truncated);
}
#[test]
fn replay_verification_keeps_non_truncated_elimination_subtraces_strict() {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut expected_builder = DecodeProof::builder(config);
expected_builder.set_received(ReceivedSummary {
total: 10,
source_count: 10,
repair_count: 0,
esi_multiset_hash: 321,
esis: (0..10).collect(),
truncated: false,
});
expected_builder.set_success(&recovered);
let elimination = expected_builder.elimination_mut();
for col in 0..=MAX_PIVOT_EVENTS {
elimination.record_inactivation(col);
}
elimination.record_pivot(3, 0);
elimination.record_strategy_transition(
InactivationStrategy::AllAtOnce,
InactivationStrategy::HighSupportFirst,
"dense_or_near_square",
);
let expected = expected_builder.build();
assert!(expected.elimination.inactive_cols_truncated);
assert!(!expected.elimination.pivot_events_truncated);
assert!(!expected.elimination.strategy_transitions_truncated);
let mut actual = expected.clone();
actual
.elimination
.pivot_events
.push(PivotEvent { col: 9, row: 1 });
let err = compare_proofs(&expected, &actual)
.expect_err("extra non-truncated pivot events must fail replay verification");
assert!(
err.to_string().contains("elimination.pivot_events"),
"mismatch should point directly at the non-truncated elimination sub-trace"
);
}
#[test]
fn replay_verification_rejects_extra_entries_in_truncated_subtraces() {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut expected_builder = DecodeProof::builder(config);
expected_builder.set_received(ReceivedSummary {
total: 10,
source_count: 10,
repair_count: 0,
esi_multiset_hash: 321,
esis: (0..10).collect(),
truncated: false,
});
expected_builder.set_success(&recovered);
let elimination = expected_builder.elimination_mut();
for col in 0..=MAX_PIVOT_EVENTS {
elimination.record_inactivation(col);
}
let expected = expected_builder.build();
assert!(expected.elimination.inactive_cols_truncated);
let mut actual = expected.clone();
actual.elimination.inactive_cols.push(MAX_PIVOT_EVENTS + 99);
let err = compare_proofs(&expected, &actual)
.expect_err("truncated previews must still reject extra recorded entries");
assert!(
err.to_string().contains("elimination.inactive_cols"),
"mismatch should point directly at the truncated elimination preview"
);
}
#[test]
fn inactivation_strategy_debug_clone_copy_default_hash_eq() {
let s = InactivationStrategy::default();
assert_eq!(s, InactivationStrategy::AllAtOnce);
let s2 = s;
assert_eq!(s, s2);
assert!(format!("{s:?}").contains("AllAtOnce"));
}
#[test]
fn inactivation_strategy_all_variants() {
let variants = [
InactivationStrategy::AllAtOnce,
InactivationStrategy::HighSupportFirst,
InactivationStrategy::BlockSchurLowRank,
];
for (i, v) in variants.iter().enumerate() {
for (j, v2) in variants.iter().enumerate() {
if i == j {
assert_eq!(v, v2);
} else {
assert_ne!(v, v2);
}
}
}
}
#[test]
fn strategy_transition_debug_clone_hash_eq() {
let t = StrategyTransition {
from: InactivationStrategy::AllAtOnce,
to: InactivationStrategy::HighSupportFirst,
reason: "escalation",
};
let t2 = t.clone();
assert_eq!(t, t2);
assert!(format!("{t:?}").contains("StrategyTransition"));
}
#[test]
fn pivot_event_debug_clone_hash_eq() {
let p = PivotEvent { col: 3, row: 7 };
let p2 = p.clone();
assert_eq!(p, p2);
assert!(format!("{p:?}").contains("PivotEvent"));
}
#[test]
fn proof_outcome_debug_clone_hash_eq() {
let success = ProofOutcome::Success {
symbols_recovered: 10,
source_payload_hash: 123,
};
let success2 = success.clone();
assert_eq!(success, success2);
assert!(format!("{success:?}").contains("Success"));
let fail = ProofOutcome::Failure {
reason: FailureReason::InsufficientSymbols {
received: 5,
required: 10,
},
};
assert_ne!(success, fail);
}
#[test]
fn failure_reason_all_variants() {
let variants: Vec<FailureReason> = vec![
FailureReason::InsufficientSymbols {
received: 1,
required: 2,
},
FailureReason::SingularMatrix {
row: 0,
attempted_cols: vec![1, 2],
},
FailureReason::SymbolSizeMismatch {
expected: 64,
actual: 32,
},
FailureReason::SymbolEquationArityMismatch {
esi: 5,
columns: 3,
coefficients: 4,
},
FailureReason::ColumnIndexOutOfRange {
esi: 1,
column: 99,
max_valid: 15,
},
FailureReason::CorruptDecodedOutput {
esi: 0,
byte_index: 7,
expected: 0xAA,
actual: 0xBB,
},
];
for v in &variants {
assert!(!format!("{v:?}").is_empty());
}
}
#[test]
fn replay_error_display_mismatch() {
let err = ReplayError::Mismatch {
field: "version",
expected: "1".into(),
actual: "2".into(),
};
let s = err.to_string();
assert!(s.contains("version"));
assert!(s.contains("expected"));
assert!(format!("{err:?}").contains("Mismatch"));
}
#[test]
fn replay_error_display_sequence() {
let err = ReplayError::SequenceMismatch {
label: "esis",
index: 5,
expected: "10".into(),
actual: "20".into(),
};
let s = err.to_string();
assert!(s.contains("esis"));
assert!(s.contains("index 5"));
}
#[test]
fn replay_error_trait() {
let err: Box<dyn std::error::Error> = Box::new(ReplayError::Mismatch {
field: "test",
expected: "a".into(),
actual: "b".into(),
});
assert!(!err.to_string().is_empty());
}
#[test]
fn decode_proof_debug_clone_eq() {
let config = make_test_config();
let recovered = make_test_recovered(&config);
let mut builder = DecodeProof::builder(config);
builder.set_received(ReceivedSummary {
total: 10,
source_count: 10,
repair_count: 0,
esi_multiset_hash: 321,
esis: (0..10).collect(),
truncated: false,
});
builder.set_success(&recovered);
let proof = builder.build();
let proof2 = proof.clone();
assert_eq!(proof, proof2);
assert!(format!("{proof:?}").contains("DecodeProof"));
}
#[test]
fn decode_proof_builder_debug() {
let builder = DecodeProof::builder(make_test_config());
assert!(format!("{builder:?}").contains("DecodeProofBuilder"));
}
#[test]
fn elimination_trace_strategy_transition_same_is_noop() {
let mut trace = EliminationTrace::default();
trace.record_strategy_transition(
InactivationStrategy::AllAtOnce,
InactivationStrategy::AllAtOnce,
"noop",
);
assert!(trace.strategy_transitions.is_empty());
assert_eq!(trace.strategy, InactivationStrategy::AllAtOnce);
}
#[test]
fn elimination_trace_strategy_transition_records() {
let mut trace = EliminationTrace::default();
trace.record_strategy_transition(
InactivationStrategy::AllAtOnce,
InactivationStrategy::HighSupportFirst,
"escalation",
);
assert_eq!(trace.strategy_transitions.len(), 1);
assert_eq!(trace.strategy, InactivationStrategy::HighSupportFirst);
}
#[test]
fn replay_verification_detects_mismatch() {
let k = 6;
let symbol_size = 24;
let seed = 17u64;
let source: Vec<Vec<u8>> = (0..k)
.map(|i| {
(0..symbol_size)
.map(|j| ((i * 41 + j * 11 + 5) % 256) as u8)
.collect()
})
.collect();
let encoder = SystematicEncoder::new(&source, symbol_size, seed).unwrap();
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let l = decoder.params().l;
let mut received = decoder.constraint_symbols();
for (i, data) in source.iter().enumerate() {
received.push(ReceivedSymbol::source(i as u32, data.clone()));
}
for esi in (k as u32)..(l as u32) {
let (cols, coefs) = decoder.repair_equation(esi);
let repair_data = encoder.repair_symbol(esi);
received.push(ReceivedSymbol::repair(esi, cols, coefs, repair_data));
}
let object_id = ObjectId::new_for_test(42);
let mut proof = decoder
.decode_with_proof(&received, object_id, 0)
.expect("decode should succeed")
.proof;
proof.elimination.row_ops = proof.elimination.row_ops.saturating_add(1);
let err = proof
.replay_and_verify(&received)
.expect_err("replay should detect mismatch");
assert!(err.to_string().contains("row_ops"));
}
#[test]
fn replay_verification_rejects_payload_divergent_success() {
let k = 8;
let symbol_size = 32;
let seed = 123u64;
let make_source = |salt: u8| -> Vec<Vec<u8>> {
(0..k)
.map(|i| {
(0..symbol_size)
.map(|j| ((i * 53 + j * 19 + usize::from(salt)) % 256) as u8)
.collect()
})
.collect()
};
let make_received =
|decoder: &InactivationDecoder, source: &[Vec<u8>]| -> Vec<ReceivedSymbol> {
let encoder = SystematicEncoder::new(source, symbol_size, seed).unwrap();
let l = decoder.params().l;
let mut received = decoder.constraint_symbols();
for (i, data) in source.iter().enumerate() {
received.push(ReceivedSymbol::source(i as u32, data.clone()));
}
for esi in (k as u32)..(l as u32) {
let (cols, coefs) = decoder.repair_equation(esi);
let repair_data = encoder.repair_symbol(esi);
received.push(ReceivedSymbol::repair(esi, cols, coefs, repair_data));
}
received
};
let source = make_source(3);
let mutated_source = make_source(11);
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let original_received = make_received(&decoder, &source);
let mutated_received = make_received(&decoder, &mutated_source);
let object_id = ObjectId::new_for_test(8080);
let proof = decoder
.decode_with_proof(&original_received, object_id, 0)
.expect("original decode should succeed")
.proof;
let mutated_result = decoder
.decode_with_proof(&mutated_received, object_id, 0)
.expect("mutated decode should still succeed");
assert_eq!(mutated_result.result.source, mutated_source);
assert_ne!(mutated_result.result.source, source);
let err = proof
.replay_and_verify(&mutated_received)
.expect_err("payload-divergent replay must fail verification");
assert!(err.to_string().contains("source_payload_hash"));
}
#[test]
fn replay_verification_rejects_high_esi_divergence_when_received_preview_truncates() {
let k = 8;
let symbol_size = 32;
let seed = 321u64;
let repair_count = MAX_RECEIVED_SYMBOLS as u32 + 32;
let source: Vec<Vec<u8>> = (0..k)
.map(|i| {
(0..symbol_size)
.map(|j| ((i * 37 + j * 17 + 9) % 256) as u8)
.collect()
})
.collect();
let encoder = SystematicEncoder::new(&source, symbol_size, seed).unwrap();
let decoder = InactivationDecoder::new(k, symbol_size, seed);
let mut original_received = decoder.constraint_symbols();
for (i, data) in source.iter().enumerate() {
original_received.push(ReceivedSymbol::source(i as u32, data.clone()));
}
for offset in 0..repair_count {
let esi = k as u32 + offset;
let (cols, coefs) = decoder.repair_equation(esi);
let repair_data = encoder.repair_symbol(esi);
original_received.push(ReceivedSymbol::repair(esi, cols, coefs, repair_data));
}
let mut mutated_received = original_received.clone();
let replaced_esi = k as u32 + repair_count - 1;
let replacement_esi = replaced_esi + 10_000;
let (replacement_cols, replacement_coefs) = decoder.repair_equation(replacement_esi);
let replacement_data = encoder.repair_symbol(replacement_esi);
let replacement = ReceivedSymbol::repair(
replacement_esi,
replacement_cols,
replacement_coefs,
replacement_data,
);
let replaced_symbol = mutated_received
.last_mut()
.expect("repair-heavy test input must contain a trailing repair symbol");
assert_eq!(replaced_symbol.esi, replaced_esi);
*replaced_symbol = replacement;
let object_id = ObjectId::new_for_test(9090);
let proof = decoder
.decode_with_proof(&original_received, object_id, 0)
.expect("original decode should succeed")
.proof;
let mutated_result = decoder
.decode_with_proof(&mutated_received, object_id, 0)
.expect("mutated decode should still succeed with enough symbols");
assert_eq!(mutated_result.result.source, source);
let original_summary =
ReceivedSummary::from_received(original_received.iter().map(|s| (s.esi, s.is_source)));
let mutated_summary =
ReceivedSummary::from_received(mutated_received.iter().map(|s| (s.esi, s.is_source)));
assert_eq!(
original_summary.esis, mutated_summary.esis,
"preview ESIs should not expose the high-ESI divergence"
);
assert_ne!(
original_summary.esi_multiset_hash, mutated_summary.esi_multiset_hash,
"full multiset binding must distinguish the mutated higher ESI"
);
let err = proof
.replay_and_verify(&mutated_received)
.expect_err("truncated received-summary replay must reject higher-ESI divergence");
assert!(err.to_string().contains("received.esi_multiset_hash"));
}
}