diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 552fd8b49..e6a61bbd3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -172,6 +172,9 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml') }} + - name: Slow Unit Tests + run: cargo test -p ipa-core --lib -- mpc_proptest semi_honest_with_dp_slow gen_binomial_noise_16_breakdowns + - name: Integration Tests - Compact Gate run: cargo test --release --test "compact_gate" --no-default-features --features "cli web-app real-world-infra test-fixture compact-gate" diff --git a/ipa-core/src/protocol/context/dzkp_validator.rs b/ipa-core/src/protocol/context/dzkp_validator.rs index 5cc726369..b5386997c 100644 --- a/ipa-core/src/protocol/context/dzkp_validator.rs +++ b/ipa-core/src/protocol/context/dzkp_validator.rs @@ -943,7 +943,6 @@ mod tests { use proptest::{ prelude::{Just, Strategy}, prop_compose, prop_oneof, proptest, - test_runner::Config as ProptestConfig, }; use rand::{distributions::Standard, prelude::Distribution}; @@ -973,7 +972,9 @@ mod tests { seq_join::{seq_join, SeqJoin}, sharding::NotSharded, test_executor::run_random, - test_fixture::{join3v, Reconstruct, Runner, TestWorld, TestWorldConfig}, + test_fixture::{ + join3v, mpc_proptest_config, Reconstruct, Runner, TestWorld, TestWorldConfig, + }, }; async fn test_select_semi_honest() @@ -1329,34 +1330,26 @@ mod tests { } proptest! { - #![proptest_config(ProptestConfig::with_cases(20))] + #![proptest_config(mpc_proptest_config())] #[test] - fn batching_proptest((record_count, max_multiplications_per_gate) in batching()) { - println!("record_count {record_count} batch {max_multiplications_per_gate}"); - // This condition is correct only for active_work = 16 and record size of 1 byte. - if max_multiplications_per_gate != 1 && max_multiplications_per_gate % 16 != 0 { - // TODO: #1300, read_size | batch_size. - // Note: for active work < 2048, read size matches active work. - - // Besides read_size | batch_size, there is also a constraint - // something like active_work > read_size + batch_size - 1. - println!("skipping config due to read_size vs. batch_size constraints"); - } else { - tokio::runtime::Runtime::new().unwrap().block_on(async { - chained_multiplies_dzkp(record_count, max_multiplications_per_gate).await.unwrap(); - /* - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - */ - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - /* - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - multi_select_malicious::(record_count, max_multiplications_per_gate).await; - */ - }); - } + fn batching_mpc_proptest( + (record_count, max_multiplications_per_gate) in batching(), + protocol in 0..8, + ) { + println!("record_count {record_count} batch {max_multiplications_per_gate} protocol {protocol}"); + tokio::runtime::Runtime::new().unwrap().block_on(async { + match protocol { + 0 => chained_multiplies_dzkp(record_count, max_multiplications_per_gate).await.unwrap(), + 1 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 2 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 3 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 4 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 5 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 6 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + 7 => multi_select_malicious::(record_count, max_multiplications_per_gate).await, + _ => unreachable!(), + } + }); } } diff --git a/ipa-core/src/protocol/hybrid/breakdown_reveal.rs b/ipa-core/src/protocol/hybrid/breakdown_reveal.rs index 2f96e9536..d94bf001c 100644 --- a/ipa-core/src/protocol/hybrid/breakdown_reveal.rs +++ b/ipa-core/src/protocol/hybrid/breakdown_reveal.rs @@ -1,4 +1,4 @@ -use std::{convert::Infallible, pin::pin}; +use std::{convert::Infallible, iter::repeat, pin::pin}; use futures::stream; use futures_util::{StreamExt, TryStreamExt}; @@ -76,6 +76,14 @@ where BitDecomposed>: for<'a> TransposeFrom<&'a [Replicated; B], Error = Infallible>, { + // This was checked early in the protocol, but we need to check again here, in case + // there were no matching pairs of reports. + if attributed_values.is_empty() { + return Ok(BitDecomposed::new( + repeat(Replicated::::ZERO).take(usize::try_from(HV::BITS).unwrap()), + )); + } + // Apply DP padding for Breakdown Reveal Aggregation let attributed_values_padded = apply_dp_padding::<_, AggregateableHybridReport, B>( ctx.narrow(&Step::PaddingDp), @@ -290,6 +298,78 @@ pub mod tests { (inputs, expectation) } + #[test] + fn empty() { + run_with::<_, _, 3>(|| async { + let world = TestWorld::>::with_shards(TestWorldConfig::default()); + let expectation = vec![0; 32]; + let inputs: Vec = vec![]; + let result: Vec<_> = world + .semi_honest(inputs.into_iter(), |ctx, input_rows| async move { + let r: Vec> = + breakdown_reveal_aggregation::<_, BA5, BA3, BA8, 32>( + ctx, + input_rows, + &PaddingParameters::no_padding(), + ) + .map_ok(|d: BitDecomposed>| { + Vec::transposed_from(&d).unwrap() + }) + .await + .unwrap(); + r + }) + .await + .reconstruct(); + let result = result + .first() + .unwrap() + .iter() + .map(|&v| v.as_u128()) + .collect::>(); + assert_eq!(32, result.len()); + assert_eq!(result, expectation); + }); + } + + #[test] + fn single() { + // Test that the output is padded to the full size, when there are not enough inputs + // for the computation to naturally grow to the full size. + run_with::<_, _, 3>(|| async { + let world = TestWorld::>::with_shards(TestWorldConfig::default()); + let mut expectation = vec![0; 32]; + expectation[0] = 7; + let expectation = expectation; // no more mutability for safety + let inputs = vec![input_row(0, 7)]; + let result: Vec<_> = world + .semi_honest(inputs.into_iter(), |ctx, input_rows| async move { + let r: Vec> = + breakdown_reveal_aggregation::<_, BA5, BA3, BA8, 32>( + ctx, + input_rows, + &PaddingParameters::no_padding(), + ) + .map_ok(|d: BitDecomposed>| { + Vec::transposed_from(&d).unwrap() + }) + .await + .unwrap(); + r + }) + .await + .reconstruct(); + let result = result + .first() + .unwrap() + .iter() + .map(|&v| v.as_u128()) + .collect::>(); + assert_eq!(32, result.len()); + assert_eq!(result, expectation); + }); + } + #[test] fn breakdown_reveal_semi_honest_happy_path() { // if shuttle executor is enabled, run this test only once. @@ -433,3 +513,164 @@ pub mod tests { }); } } + +#[cfg(all(test, unit_test))] +mod proptests { + use std::{cmp::min, time::Duration}; + + use futures::TryFutureExt; + use proptest::{prelude::*, prop_compose}; + + use crate::{ + const_assert, + ff::{ + boolean::Boolean, + boolean_array::{BA3, BA32, BA5, BA8}, + U128Conversions, + }, + protocol::{ + hybrid::breakdown_reveal::breakdown_reveal_aggregation, + ipa_prf::oprf_padding::PaddingParameters, + }, + secret_sharing::{ + replicated::semi_honest::AdditiveShare as Replicated, BitDecomposed, SharedValue, + TransposeFrom, + }, + test_fixture::{ + hybrid::{TestAggregateableHybridReport, TestIndistinguishableHybridReport}, + mpc_proptest_config_with_cases, Reconstruct, Runner, TestWorld, TestWorldConfig, + WithShards, + }, + }; + + type PropBreakdownKey = BA5; + type PropTriggerValue = BA3; + type PropHistogramValue = BA8; + type PropBucketsBitVec = BA32; + const PROP_MAX_INPUT_LEN: usize = 2500; + const PROP_BUCKETS: usize = PropBucketsBitVec::BITS as usize; + const PROP_SHARDS: usize = 2; + + // We want to capture everything in this struct for visibility in the output of failing runs, + // even if it isn't used by the test. + #[allow(dead_code)] + #[derive(Debug)] + struct AggregatePropTestInputs { + inputs: Vec, + expected: Vec, + len: usize, + } + + const_assert!( + PropHistogramValue::BITS < u32::BITS, + "(1 << PropHistogramValue::BITS) must fit in u32", + ); + + const_assert!( + PROP_BUCKETS <= 1 << PropBreakdownKey::BITS, + "PROP_BUCKETS must fit in PropBreakdownKey", + ); + + impl From<(u32, u32)> for TestAggregateableHybridReport { + fn from(value: (u32, u32)) -> Self { + TestAggregateableHybridReport { + match_key: (), + breakdown_key: value.0, + value: value.1, + } + } + } + + prop_compose! { + fn inputs(max_len: usize) + ( + len in 0..=max_len, + ) + ( + len in Just(len), + inputs in prop::collection::vec(( + 0u32..u32::try_from(PROP_BUCKETS).unwrap(), + 0u32..1 << PropTriggerValue::BITS, + ).prop_map(Into::into), len), + ) + -> AggregatePropTestInputs { + let mut expected = vec![0; PROP_BUCKETS]; + for input in &inputs { + let TestIndistinguishableHybridReport { + match_key: (), + breakdown_key: bk, + value: tv, + } = *input; + let bk = usize::try_from(bk).unwrap(); + expected[bk] = min(expected[bk] + tv, (1 << PropHistogramValue::BITS) - 1); + } + + AggregatePropTestInputs { + inputs, + expected, + len, + } + } + } + + proptest! { + #![proptest_config(mpc_proptest_config_with_cases(100))] + #[test] + fn breakdown_reveal_mpc_proptest( + input_struct in inputs(PROP_MAX_INPUT_LEN), + seed in any::(), + ) { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let AggregatePropTestInputs { + inputs, + expected, + .. + } = input_struct; + let config = TestWorldConfig { + seed, + timeout: Some(Duration::from_secs(20)), + ..Default::default() + }; + let result = TestWorld::>::with_config(&config) + .malicious(inputs.into_iter(), |ctx, inputs| async move { + breakdown_reveal_aggregation::< + _, + PropBreakdownKey, + PropTriggerValue, + PropHistogramValue, + {PropBucketsBitVec::BITS as usize}, + >( + ctx, + inputs, + &PaddingParameters::no_padding(), + ) + .map_ok(|d: BitDecomposed>| { + Vec::transposed_from(&d).unwrap() + }) + .await + .unwrap() + }) + .await + .reconstruct(); + + let initial = vec![0; PROP_BUCKETS]; + let result = result + .iter() + .fold(initial, |mut acc, vec: &Vec| { + acc.iter_mut() + .zip(vec) + .for_each(|(a, &b)| { + *a = min( + *a + u32::try_from(b.as_u128()).unwrap(), + (1 << PropHistogramValue::BITS) - 1, + ); + }); + acc + }) + .into_iter() + .collect::>(); + assert_eq!(result, expected); + }); + } + } +} diff --git a/ipa-core/src/protocol/ipa_prf/aggregation/breakdown_reveal.rs b/ipa-core/src/protocol/ipa_prf/aggregation/breakdown_reveal.rs index 8b799d0ac..a850e4807 100644 --- a/ipa-core/src/protocol/ipa_prf/aggregation/breakdown_reveal.rs +++ b/ipa-core/src/protocol/ipa_prf/aggregation/breakdown_reveal.rs @@ -203,10 +203,19 @@ where intermediate_results = next_intermediate_results; } - Ok(intermediate_results + let mut result = intermediate_results .into_iter() .next() - .expect("aggregation input must not be empty")) + .expect("aggregation input must not be empty"); + + // If there were less than 2^(|ov| - |tv|) inputs, then we didn't add enough carries to produce + // a full-length output, so pad the output now. + result.resize( + usize::try_from(HV::BITS).unwrap(), + Replicated::::ZERO, + ); + + Ok(result) } /// Transforms the Breakdown key from a secret share into a revealed `usize`. @@ -302,29 +311,39 @@ where #[cfg(all(test, any(unit_test, feature = "shuttle")))] pub mod tests { + + use std::cmp::min; + use futures::TryFutureExt; + use proptest::{prelude::*, prop_compose, proptest}; use rand::seq::SliceRandom; - #[cfg(not(feature = "shuttle"))] - use crate::{ff::boolean_array::BA16, test_executor::run}; use crate::{ + const_assert, ff::{ boolean::Boolean, - boolean_array::{BA3, BA5, BA8}, + boolean_array::{BA3, BA32, BA5, BA8}, U128Conversions, }, protocol::ipa_prf::{ aggregation::breakdown_reveal::breakdown_reveal_aggregation, oprf_padding::PaddingParameters, - prf_sharding::{AttributionOutputsTestInput, SecretSharedAttributionOutputs}, + prf_sharding::{ + AttributionOutputs, AttributionOutputsTestInput, SecretSharedAttributionOutputs, + }, }, rand::Rng, secret_sharing::{ - replicated::semi_honest::AdditiveShare as Replicated, BitDecomposed, TransposeFrom, + replicated::semi_honest::AdditiveShare as Replicated, BitDecomposed, IntoShares, + SharedValue, TransposeFrom, }, test_executor::run_with, - test_fixture::{Reconstruct, Runner, TestWorld}, + test_fixture::{ + mpc_proptest_config_with_cases, Reconstruct, ReconstructArr, Runner, TestWorld, + }, }; + #[cfg(not(feature = "shuttle"))] + use crate::{ff::boolean_array::BA16, test_executor::run}; fn input_row(bk: usize, tv: u128) -> AttributionOutputsTestInput { let bk: u128 = bk.try_into().unwrap(); @@ -334,6 +353,46 @@ pub mod tests { } } + #[test] + fn single() { + // Test that the output is padded to the full size, when there are not enough inputs + // for the computation to naturally grow to the full size. + run_with::<_, _, 3>(|| async { + let world = TestWorld::default(); + let mut expectation = vec![0; 32]; + expectation[0] = 7; + let expectation = expectation; // no more mutability for safety + let inputs = vec![input_row(0, 7)]; + let result: Vec<_> = world + .semi_honest(inputs.into_iter(), |ctx, input_rows| async move { + let aos = input_rows + .into_iter() + .map(|ti| SecretSharedAttributionOutputs { + attributed_breakdown_key_bits: ti.0, + capped_attributed_trigger_value: ti.1, + }) + .collect(); + let r: Vec> = + breakdown_reveal_aggregation::<_, BA5, BA3, BA8, 32>( + ctx, + aos, + &PaddingParameters::no_padding(), + ) + .map_ok(|d: BitDecomposed>| { + Vec::transposed_from(&d).unwrap() + }) + .await + .unwrap(); + r + }) + .await + .reconstruct(); + let result = result.iter().map(|&v| v.as_u128()).collect::>(); + assert_eq!(32, result.len()); + assert_eq!(result, expectation); + }); + } + #[test] fn semi_honest_happy_path() { // if shuttle executor is enabled, run this test only once. @@ -445,4 +504,128 @@ pub mod tests { assert_eq!(result, expectation); }); } + + type PropBreakdownKey = BA5; + type PropTriggerValue = BA3; + type PropHistogramValue = BA8; + type PropBucketsBitVec = BA32; + const PROP_MAX_INPUT_LEN: usize = 2500; + const PROP_BUCKETS: usize = PropBucketsBitVec::BITS as usize; + + // We want to capture everything in this struct for visibility in the output of failing runs, + // even if it isn't used by the test. + #[allow(dead_code)] + #[derive(Debug)] + struct AggregatePropTestInputs { + inputs: Vec>, + expected: BitDecomposed, + len: usize, + } + + const_assert!( + PropHistogramValue::BITS < u32::BITS, + "(1 << PropHistogramValue::BITS) must fit in u32", + ); + + const_assert!( + PROP_BUCKETS <= 1 << PropBreakdownKey::BITS, + "PROP_BUCKETS must fit in PropBreakdownKey", + ); + + impl From<(BK, TV)> for AttributionOutputs { + fn from(value: (BK, TV)) -> Self { + AttributionOutputs { + attributed_breakdown_key_bits: value.0, + capped_attributed_trigger_value: value.1, + } + } + } + + impl IntoShares> + for AttributionOutputs + { + fn share_with( + self, + rng: &mut R, + ) -> [SecretSharedAttributionOutputs; 3] { + let [bk_0, bk_1, bk_2] = PropBreakdownKey::truncate_from( + u128::try_from(self.attributed_breakdown_key_bits).unwrap(), + ) + .share_with(rng); + let [tv_0, tv_1, tv_2] = + PropTriggerValue::truncate_from(u128::from(self.capped_attributed_trigger_value)) + .share_with(rng); + [(bk_0, tv_0), (bk_1, tv_1), (bk_2, tv_2)].map(Into::into) + } + } + + prop_compose! { + fn inputs(max_len: usize) + ( + len in 1..=max_len, + ) + ( + len in Just(len), + inputs in prop::collection::vec(( + 0..PROP_BUCKETS, + 0u32..1 << PropTriggerValue::BITS, + ).prop_map(Into::into), len), + ) + -> AggregatePropTestInputs { + let mut expected = [0; PROP_BUCKETS]; + for input in &inputs { + let AttributionOutputs { + attributed_breakdown_key_bits: bk, + capped_attributed_trigger_value: tv, + } = *input; + expected[bk] = min(expected[bk] + tv, (1 << PropHistogramValue::BITS) - 1); + } + + let expected = BitDecomposed::decompose(PropHistogramValue::BITS, |i| { + expected.iter().map(|v| Boolean::from((v >> i) & 1 == 1)).collect() + }); + + AggregatePropTestInputs { + inputs, + expected, + len, + } + } + } + + proptest! { + #![proptest_config(mpc_proptest_config_with_cases(100))] + #[test] + #[ignore] // This test is similar enough to the one in hybrid::breakdown_reveal + // that it is not worth running both. + fn breakdown_reveal_mpc_proptest( + input_struct in inputs(PROP_MAX_INPUT_LEN), + seed in any::(), + ) { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let AggregatePropTestInputs { + inputs, + expected, + .. + } = input_struct; + let result = TestWorld::with_seed(seed) + .malicious(inputs.into_iter(), |ctx, inputs| async move { + breakdown_reveal_aggregation::< + _, _, _, + PropHistogramValue, + {PropBucketsBitVec::BITS as usize}, + >( + ctx, + inputs, + &PaddingParameters::no_padding(), + ).await + }) + .await + .map(Result::unwrap) + .reconstruct_arr(); + + assert_eq!(result, expected); + }); + } + } } diff --git a/ipa-core/src/protocol/ipa_prf/aggregation/mod.rs b/ipa-core/src/protocol/ipa_prf/aggregation/mod.rs index e8d7631b7..0e8f43ed6 100644 --- a/ipa-core/src/protocol/ipa_prf/aggregation/mod.rs +++ b/ipa-core/src/protocol/ipa_prf/aggregation/mod.rs @@ -257,7 +257,7 @@ pub mod tests { helpers::Role, secret_sharing::{BitDecomposed, SharedValue}, test_executor::run, - test_fixture::{ReconstructArr, Runner, TestWorld}, + test_fixture::{mpc_proptest_config, ReconstructArr, Runner, TestWorld}, }; fn input_row(tv_bits: usize, values: &[u32]) -> BitDecomposed<[Boolean; B]> { @@ -482,7 +482,7 @@ pub mod tests { // Any of the supported aggregation configs can be used here (search for "aggregation output" in // transpose.rs). This small config keeps CI runtime within reason, however, it does not exercise // saturated addition at the output. - const PROP_MAX_INPUT_LEN: usize = 10; + const PROP_MAX_INPUT_LEN: usize = 100; const PROP_MAX_TV_BITS: usize = 3; // Limit: (1 << TV_BITS) must fit in u32 const PROP_BUCKETS: usize = 8; type PropHistogramValue = BA8; @@ -535,8 +535,9 @@ pub mod tests { } proptest! { + #![proptest_config(mpc_proptest_config())] #[test] - fn aggregate_values_proptest( + fn aggregate_values_mpc_proptest( input_struct in arb_aggregate_values_inputs(PROP_MAX_INPUT_LEN), seed in any::(), ) { diff --git a/ipa-core/src/test_fixture/mod.rs b/ipa-core/src/test_fixture/mod.rs index 3470c08f5..878e4a827 100644 --- a/ipa-core/src/test_fixture/mod.rs +++ b/ipa-core/src/test_fixture/mod.rs @@ -197,3 +197,34 @@ pub fn bits_to_value(x: &[F]) -> u128 { pub fn bits_to_field(x: &[F]) -> F { F::try_from(bits_to_value(x)).unwrap() } + +/// Useful proptest configuration when testing MPC protocols. +/// +/// If you are using this config in a test, consider putting `mpc_proptest` in the name +/// of the test, so it is included in the CI run of slow tests. +#[cfg(test)] +#[must_use] +pub fn mpc_proptest_config() -> proptest::prelude::ProptestConfig { + mpc_proptest_config_with_cases(proptest::prelude::ProptestConfig::default().cases) +} + +#[cfg(test)] +#[must_use] +pub fn mpc_proptest_config_with_cases(cases: u32) -> proptest::prelude::ProptestConfig { + use std::cmp::max; + + // TestWorld imposes a per-iteration timeout. In addition to that, we want to limit + // the shrinking time, since most of the shrinking attempts for a timeout case, are + // likely to also time out. Proptest also has a timeout mechanism, but it interacts + // badly with max_shrink_time, and is mostly redundant with the TestWorld timeout. + let mut config = proptest::prelude::ProptestConfig { + max_shrink_time: 60_000, + cases, + ..Default::default() + }; + if std::env::var("EXEC_SLOW_TESTS").is_err() { + // Reduce the number of cases when not in slow-tests mode. + config.cases = max(cases / 20, 1); + } + config +} diff --git a/ipa-core/src/test_fixture/world.rs b/ipa-core/src/test_fixture/world.rs index bba3547f5..601eede12 100644 --- a/ipa-core/src/test_fixture/world.rs +++ b/ipa-core/src/test_fixture/world.rs @@ -396,8 +396,8 @@ impl TestWorld { }; if let Some(timeout) = timeout { let Ok(output) = tokio::time::timeout(timeout, fut).await else { - tracing::error!("timed out after {:?}", self.timeout); - panic!("timed out after {:?}", self.timeout); + tracing::error!("timed out after {timeout:?}"); + panic!("timed out after {timeout:?}"); }; output } else {