Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cesr/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed (breaking)

- **`serder::ample`** now matches keripy's `ample()` exactly (`eventing.py`,
keripy `de59bc7d`): `ample(3)` returns `3` (was `2`), so a 3-witness
inception built without an explicit witness threshold emits `"bt":"3"`
like keripy. The signature is now fallible —
`ample(n: usize) -> Result<u32, SerderError>` — replacing the
`unwrap_or(u32::MAX)` sentinel conversion with a typed
`SerderError::Validation` when the threshold exceeds `u32` (#147).

## [0.5.0](https://github.com/devrandom-labs/cesr/compare/cesr-rs-v0.4.0...cesr-rs-v0.5.0) - 2026-07-08

### Added
Expand Down
142 changes: 123 additions & 19 deletions cesr/src/serder/ample.rs
Original file line number Diff line number Diff line change
@@ -1,57 +1,161 @@
//! BFT witness threshold computation ported from keripy `eventing.py`.

/// Compute the sufficient immune majority threshold for `n` witnesses.
use alloc::format;

use crate::serder::error::SerderError;

/// Compute the sufficient immune (ample) majority threshold for `n` witnesses.
///
/// Port of keripy `ample(n, f=None, weak=True)` (`eventing.py`, keripy
/// `de59bc7d`): for the maximum fault count `f` satisfying `n >= 3f + 1`,
/// minimize `m` subject to `(n + f + 1) / 2 <= m <= n - f`. Both the floor
/// and ceiling candidates for `f` are tried and the smaller `m` wins.
/// Returns `0` when `n` is `0`.
///
/// For `n` witnesses, returns `ceil(n * 2/3)` which is the minimum number of
/// witness receipts needed for Byzantine fault tolerance. Returns `0` when
/// `n` is `0`.
/// # Errors
///
/// Ported from keripy `ample()` in `eventing.py`.
#[must_use]
pub fn ample(n: usize) -> u32 {
if n == 0 {
return 0;
}
let f = (n * 2).div_ceil(3);
u32::try_from(f.max(1)).unwrap_or(u32::MAX)
/// Returns [`SerderError::Validation`] when the computed threshold exceeds
/// `u32::MAX` (witness counts beyond the KERI `bt` field's range).
pub fn ample(n: usize) -> Result<u32, SerderError> {
let Some(faultable) = n.checked_sub(1) else {
return Ok(0);
};
let f_floor = (faultable / 3).max(1);
let f_ceil = faultable.div_ceil(3).max(1);
let m_floor = least_strong_majority(n, f_floor)?;
let m_ceil = least_strong_majority(n, f_ceil)?;
let threshold = n.min(m_floor).min(m_ceil);
u32::try_from(threshold).map_err(|_| threshold_overflow(n))
}

/// Least `m` satisfying the strong-majority lower bound `m >= (n + f + 1) / 2`
/// for `f` faulty witnesses out of `n` (keripy `ceil((n + f + 1) / 2)`).
fn least_strong_majority(n: usize, f: usize) -> Result<usize, SerderError> {
n.checked_add(f)
.and_then(|sum| sum.checked_add(1))
.map(|sum| sum.div_ceil(2))
.ok_or_else(|| threshold_overflow(n))
}

fn threshold_overflow(n: usize) -> SerderError {
SerderError::Validation(format!(
"witness threshold for {n} witnesses exceeds the supported u32 range"
))
}

#[cfg(test)]
mod tests {
use proptest::prelude::*;

use super::*;

/// Verbatim transcription of keripy `ample(n, f=None, weak=True)`
/// (`eventing.py:89-94`, keripy `de59bc7d`). Python's `max(0, n - 1)`
/// is `saturating_sub(1)` and `ceil(a / b)` on non-negative ints is
/// `div_ceil`; both are exact for the bounded test domain.
fn keripy_oracle(n: usize) -> usize {
let f1 = 1usize.max(n.saturating_sub(1) / 3);
let f2 = 1usize.max(n.saturating_sub(1).div_ceil(3));
n.min((n + f1 + 1).div_ceil(2))
.min((n + f2 + 1).div_ceil(2))
}

/// Expectations lifted from keripy `tests/core/test_eventing_v1.py`
/// `test_ample` (weak defaults), keripy `de59bc7d` / v2.0.0.dev5.
#[test]
fn matches_keripy_test_eventing_v1_table() {
let expected = [
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 3),
(5, 4),
(6, 4),
(7, 5),
(8, 6),
(9, 6),
(10, 7),
(11, 8),
(12, 8),
(13, 9),
];
for (n, want) in expected {
assert_eq!(ample(n).unwrap(), want, "ample({n})");
}
}

#[test]
fn ample_zero_witnesses() {
assert_eq!(ample(0), 0);
assert_eq!(ample(0).unwrap(), 0);
}

#[test]
fn ample_one_witness() {
assert_eq!(ample(1), 1);
assert_eq!(ample(1).unwrap(), 1);
}

#[test]
fn ample_two_witnesses() {
assert_eq!(ample(2), 2);
assert_eq!(ample(2).unwrap(), 2);
}

#[test]
fn ample_three_witnesses() {
assert_eq!(ample(3), 2);
assert_eq!(ample(3).unwrap(), 3);
}

#[test]
fn ample_four_witnesses() {
assert_eq!(ample(4), 3);
assert_eq!(ample(4).unwrap(), 3);
}

#[test]
fn ample_six_witnesses() {
assert_eq!(ample(6), 4);
assert_eq!(ample(6).unwrap(), 4);
}

#[test]
fn ample_ten_witnesses() {
assert_eq!(ample(10), 7);
assert_eq!(ample(10).unwrap(), 7);
}

#[cfg(target_pointer_width = "64")]
#[test]
fn ample_errors_when_intermediate_sum_overflows() {
let err = ample(usize::MAX).unwrap_err();
assert!(matches!(err, SerderError::Validation(_)));
}

#[cfg(target_pointer_width = "64")]
#[test]
fn ample_errors_when_threshold_exceeds_u32() {
let n = 7_000_000_000usize;
let err = ample(n).unwrap_err();
assert!(matches!(err, SerderError::Validation(_)));
}

/// Random sampling alone can miss a single divergent point (256 draws
/// over 257 values skip any given `n` ~37% of the time), so the full
/// issue-#147 range is also swept exhaustively.
#[test]
fn matches_keripy_oracle_exhaustively_to_256() {
for n in 0usize..=256 {
assert_eq!(
ample(n).unwrap(),
u32::try_from(keripy_oracle(n)).unwrap(),
"ample({n})"
);
}
}

proptest! {
#[test]
fn matches_keripy_oracle(n in 0usize..=256) {
prop_assert_eq!(
ample(n).unwrap(),
u32::try_from(keripy_oracle(n)).unwrap()
);
}
}
}
8 changes: 5 additions & 3 deletions cesr/src/serder/builder/dip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::core::primitives::{Diger, Prefixer, Seqner, Tholder, Verfer};
use crate::keri::{ConfigTrait, DelegatedInceptionEvent, Identifier, InceptionEvent, Seal};

use super::icp::{dummy_prefixer, dummy_saider, majority, validate_threshold};
use crate::serder::ample::ample;
use crate::serder::error::SerderError;
use crate::serder::serialize::SerializedEvent;

Expand Down Expand Up @@ -189,9 +190,10 @@ impl DelegatedInceptionBuilder<Ready> {
validate_threshold(&next_threshold, self.next_keys.len(), "next signing")?;
}

let witness_threshold = self
.witness_threshold
.unwrap_or_else(|| crate::serder::ample::ample(self.witnesses.len()));
let witness_threshold = match self.witness_threshold {
Some(explicit) => explicit,
None => ample(self.witnesses.len())?,
};

let delegator = self
.delegator
Expand Down
10 changes: 6 additions & 4 deletions cesr/src/serder/builder/icp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::core::matter::code::{DigestCode, VerKeyCode};
use crate::core::primitives::{Diger, Prefixer, Saider, Seqner, Tholder, Verfer};
use crate::keri::{ConfigTrait, InceptionEvent, Seal};

use crate::serder::ample::ample;
use crate::serder::error::SerderError;
use crate::serder::serialize::SerializedEvent;

Expand Down Expand Up @@ -184,9 +185,10 @@ impl InceptionBuilder<Ready> {
validate_threshold(&next_threshold, self.next_keys.len(), "next signing")?;
}

let witness_threshold = self
.witness_threshold
.unwrap_or_else(|| crate::serder::ample::ample(self.witnesses.len()));
let witness_threshold = match self.witness_threshold {
Some(explicit) => explicit,
None => ample(self.witnesses.len())?,
};

let event = InceptionEvent::new(
dummy_prefixer()?.into(),
Expand Down Expand Up @@ -327,7 +329,7 @@ mod tests {
.unwrap();

let parsed: serde_json::Value = serde_json::from_slice(result.as_bytes()).unwrap();
assert_eq!(parsed["bt"].as_str().unwrap(), "2");
assert_eq!(parsed["bt"].as_str().unwrap(), "3");
}

#[test]
Expand Down
Loading