use super::{
reduce_all, reduce_family_polarity, reduce_with_attribution, EvidenceEvent, EvidencePolarity,
CONTRADICTORY_SCHEDULE, PER_GROUP_CAP, POSITIVE_SCHEDULE,
};
use crate::existence::families::SignalFamily;
use proptest::prelude::*;
const IDS: &[&str] = &["a", "b", "c", "d"];
const FAMILIES: &[SignalFamily] = &[
SignalFamily::Range,
SignalFamily::CacheValidator,
SignalFamily::Auth,
SignalFamily::Precondition,
SignalFamily::Negotiation,
SignalFamily::ErrorBody,
SignalFamily::Redirect,
SignalFamily::General,
];
fn approx_eq(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn ev(family: SignalFamily, id: &'static str, weight: f64, pol: EvidencePolarity) -> EvidenceEvent {
match pol {
EvidencePolarity::Positive => EvidenceEvent::positive(family, id, weight, weight),
EvidencePolarity::Contradictory => EvidenceEvent::contradictory(family, id, weight, weight),
}
}
fn pos(family: SignalFamily, id: &'static str, weight: f64) -> EvidenceEvent {
EvidenceEvent::positive(family, id, weight, weight)
}
fn neg(family: SignalFamily, id: &'static str, weight: f64) -> EvidenceEvent {
EvidenceEvent::contradictory(family, id, weight, weight)
}
#[test]
fn reduce_family_polarity_empty_returns_zero() {
let r = reduce_family_polarity(&[], CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r, 0.0));
}
#[test]
fn reduce_family_polarity_single_contradictory_event_full_slot() {
let events = [neg(SignalFamily::Auth, "a", 0.25)];
let r = reduce_family_polarity(&events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r, -0.25));
}
#[test]
fn reduce_family_polarity_two_contradictory_events_apply_first_two_slots() {
let events = [
neg(SignalFamily::Auth, "a", 0.25),
neg(SignalFamily::Auth, "b", 0.25),
];
let r = reduce_family_polarity(&events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r, -0.25 - 0.25 * 0.7));
}
#[test]
fn reduce_family_polarity_events_beyond_schedule_contribute_zero() {
let events: Vec<EvidenceEvent> = (0..6)
.map(|i| neg(SignalFamily::Auth, IDS[i % IDS.len()], 0.1))
.collect();
let r = reduce_family_polarity(&events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
let expected: f64 = CONTRADICTORY_SCHEDULE.iter().map(|m| -0.1 * m).sum();
assert!(approx_eq(r, expected));
}
#[test]
fn reduce_family_polarity_clamps_to_negative_cap() {
let events: Vec<EvidenceEvent> = IDS
.iter()
.map(|id| neg(SignalFamily::Auth, id, 0.6))
.collect();
let r = reduce_family_polarity(&events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r, -PER_GROUP_CAP));
}
#[test]
fn reduce_family_polarity_clamps_to_positive_cap() {
let events: Vec<EvidenceEvent> = IDS
.iter()
.map(|id| pos(SignalFamily::Auth, id, 0.6))
.collect();
let r = reduce_family_polarity(&events, POSITIVE_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r, PER_GROUP_CAP));
}
#[test]
fn reduce_family_polarity_sorts_by_weight_descending() {
let asc = vec![
pos(SignalFamily::Range, "a", 0.1),
pos(SignalFamily::Range, "b", 0.2),
pos(SignalFamily::Range, "c", 0.4),
];
let mut desc = asc.clone();
desc.reverse();
let asc_r = reduce_family_polarity(&asc, POSITIVE_SCHEDULE, PER_GROUP_CAP);
let desc_r = reduce_family_polarity(&desc, POSITIVE_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(asc_r, desc_r));
assert!(approx_eq(asc_r, 0.4 + 0.2 * 0.5 + 0.1 * 0.25));
}
#[test]
fn reduce_all_empty_returns_zero() {
assert!(approx_eq(reduce_all(&[]), 0.0));
}
#[test]
fn reduce_all_single_family_single_polarity_matches_group_reduce() {
let events = [
neg(SignalFamily::Auth, "a", 0.25),
neg(SignalFamily::Auth, "b", 0.25),
];
let group = reduce_family_polarity(&events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
let total = reduce_all(&events);
assert!(approx_eq(group, total));
}
#[test]
fn reduce_all_distinct_families_sum_independently() {
let events = [
neg(SignalFamily::Auth, "a", 0.3),
neg(SignalFamily::Range, "a", 0.3),
];
assert!(approx_eq(reduce_all(&events), -0.6));
}
#[test]
fn reduce_all_positive_and_contradictory_independent_within_family() {
let events = [
pos(SignalFamily::Auth, "a", 0.4),
neg(SignalFamily::Auth, "b", 0.4),
];
assert!(approx_eq(reduce_all(&events), 0.0));
}
fn arb_family() -> impl Strategy<Value = SignalFamily> {
(0usize..FAMILIES.len()).prop_map(|i| FAMILIES[i])
}
fn arb_polarity() -> impl Strategy<Value = EvidencePolarity> {
prop_oneof![
Just(EvidencePolarity::Positive),
Just(EvidencePolarity::Contradictory),
]
}
fn arb_id() -> impl Strategy<Value = &'static str> {
(0usize..IDS.len()).prop_map(|i| IDS[i])
}
fn arb_event() -> impl Strategy<Value = EvidenceEvent> {
(arb_family(), arb_polarity(), arb_id(), 0.0f64..=1.0f64)
.prop_map(|(f, p, id, w)| ev(f, id, w, p))
}
fn arb_event_vec() -> impl Strategy<Value = Vec<EvidenceEvent>> {
prop::collection::vec(arb_event(), 0..16)
}
fn permute(len: usize, seeds: &[u32]) -> Vec<usize> {
let mut indices: Vec<usize> = (0..len).collect();
for (offset, i) in (1..len).rev().enumerate() {
let seed = seeds.get(offset).copied().unwrap_or(0);
let j = (seed as usize) % (i + 1);
indices.swap(i, j);
}
indices
}
proptest! {
#[test]
fn reduce_all_is_order_invariant(
events in arb_event_vec(),
seeds in prop::collection::vec(any::<u32>(), 0..16),
) {
let perm = permute(events.len(), &seeds);
let permuted: Vec<EvidenceEvent> = perm.into_iter().map(|i| events[i].clone()).collect();
let a = reduce_all(&events);
let b = reduce_all(&permuted);
prop_assert!((a - b).abs() < 1e-9);
}
#[test]
fn reduce_family_polarity_respects_cap(events in arb_event_vec()) {
let positive: Vec<EvidenceEvent> = events
.iter().filter(|e| e.polarity == EvidencePolarity::Positive).cloned().collect();
let contradictory: Vec<EvidenceEvent> = events
.iter().filter(|e| e.polarity == EvidencePolarity::Contradictory).cloned().collect();
let p = reduce_family_polarity(&positive, POSITIVE_SCHEDULE, PER_GROUP_CAP);
let c = reduce_family_polarity(&contradictory, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
prop_assert!(p.abs() <= PER_GROUP_CAP + 1e-9);
prop_assert!(c.abs() <= PER_GROUP_CAP + 1e-9);
}
#[test]
fn adding_zero_weight_event_does_not_change_result(
events in arb_event_vec(),
family in arb_family(),
polarity in arb_polarity(),
id in arb_id(),
) {
let baseline = reduce_all(&events);
let mut augmented = events;
augmented.push(ev(family, id, 0.0, polarity));
let with_zero = reduce_all(&augmented);
prop_assert!((baseline - with_zero).abs() < 1e-9);
}
#[test]
fn polarity_only_groups_have_consistent_sign(events in arb_event_vec()) {
let pos_events: Vec<EvidenceEvent> = events
.iter().filter(|e| e.polarity == EvidencePolarity::Positive).cloned().collect();
let neg_events: Vec<EvidenceEvent> = events
.into_iter().filter(|e| e.polarity == EvidencePolarity::Contradictory).collect();
let p = reduce_family_polarity(&pos_events, POSITIVE_SCHEDULE, PER_GROUP_CAP);
let n = reduce_family_polarity(&neg_events, CONTRADICTORY_SCHEDULE, PER_GROUP_CAP);
prop_assert!(p >= -1e-12);
prop_assert!(n <= 1e-12);
}
#[test]
fn schedule_consumed_in_slot_order_regardless_of_input_order(
weights in prop::collection::vec(0.0f64..=1.0f64, 1..6),
) {
let f = SignalFamily::General;
let mut asc: Vec<EvidenceEvent> = weights
.iter().enumerate().map(|(i, &w)| pos(f, IDS[i % IDS.len()], w)).collect();
asc.sort_by(|a, b| a.weight.partial_cmp(&b.weight).unwrap_or(std::cmp::Ordering::Equal));
let mut desc = asc.clone();
desc.reverse();
let asc_r = reduce_family_polarity(&asc, POSITIVE_SCHEDULE, PER_GROUP_CAP);
let desc_r = reduce_family_polarity(&desc, POSITIVE_SCHEDULE, PER_GROUP_CAP);
prop_assert!((asc_r - desc_r).abs() < 1e-9);
}
}
#[test]
fn attribution_empty_returns_empty() {
let r = reduce_with_attribution(&[]);
assert!(approx_eq(r.total_log_odds, 0.0));
assert!(r.contributions.is_empty());
}
#[test]
fn attribution_parallel_to_input_index() {
let events = vec![
pos(SignalFamily::Range, "a", 0.4),
neg(SignalFamily::Auth, "b", 0.3),
pos(SignalFamily::Range, "c", 0.1),
];
let r = reduce_with_attribution(&events);
assert_eq!(r.contributions.len(), events.len());
}
#[test]
fn attribution_sum_equals_total() {
let events = vec![
pos(SignalFamily::Range, "a", 0.4),
pos(SignalFamily::Range, "b", 0.2),
neg(SignalFamily::Auth, "c", 0.3),
];
let r = reduce_with_attribution(&events);
let sum: f64 = r.contributions.iter().sum();
assert!(approx_eq(sum, r.total_log_odds));
}
#[test]
fn attribution_no_cap_matches_schedule_per_event() {
let events = vec![
pos(SignalFamily::Range, "a", 0.4),
pos(SignalFamily::Range, "b", 0.2),
];
let r = reduce_with_attribution(&events);
assert!(approx_eq(r.contributions[0], 0.4));
assert!(approx_eq(r.contributions[1], 0.2 * 0.5));
}
#[test]
fn attribution_when_capped_group_sums_to_cap() {
let events: Vec<EvidenceEvent> = IDS
.iter()
.map(|id| pos(SignalFamily::Range, id, 0.6))
.collect();
let r = reduce_with_attribution(&events);
let sum: f64 = r.contributions.iter().sum();
assert!(approx_eq(sum, PER_GROUP_CAP));
assert!(approx_eq(r.total_log_odds, PER_GROUP_CAP));
}
#[test]
fn attribution_total_matches_reduce_all() {
let events = vec![
pos(SignalFamily::Range, "a", 0.4),
pos(SignalFamily::Range, "b", 0.2),
neg(SignalFamily::Auth, "c", 0.3),
neg(SignalFamily::Auth, "d", 0.1),
];
let direct = reduce_all(&events);
let r = reduce_with_attribution(&events);
assert!(approx_eq(r.total_log_odds, direct));
}
proptest! {
#[test]
fn attribution_sums_to_total_property(events in arb_event_vec()) {
let r = reduce_with_attribution(&events);
let sum: f64 = r.contributions.iter().sum();
prop_assert!((sum - r.total_log_odds).abs() < 1e-9);
}
#[test]
fn attribution_parallel_indexing_property(events in arb_event_vec()) {
let r = reduce_with_attribution(&events);
prop_assert_eq!(r.contributions.len(), events.len());
}
#[test]
fn attribution_total_matches_reduce_all_property(events in arb_event_vec()) {
let direct = reduce_all(&events);
let r = reduce_with_attribution(&events);
prop_assert!((r.total_log_odds - direct).abs() < 1e-9);
}
#[test]
fn attribution_total_order_invariant(
events in arb_event_vec(),
seeds in prop::collection::vec(any::<u32>(), 0..16),
) {
let perm = permute(events.len(), &seeds);
let permuted: Vec<EvidenceEvent> = perm.into_iter().map(|i| events[i].clone()).collect();
let a = reduce_with_attribution(&events).total_log_odds;
let b = reduce_with_attribution(&permuted).total_log_odds;
prop_assert!((a - b).abs() < 1e-9);
}
}
#[test]
fn technique_id_tiebreaker_smaller_id_wins_slot_0() {
let family = SignalFamily::General;
let weight = 0.3_f64;
let events_alpha_first = vec![
EvidenceEvent::positive(family, "alpha".to_owned(), weight, weight),
EvidenceEvent::positive(family, "beta".to_owned(), weight, weight),
];
let events_beta_first = vec![
EvidenceEvent::positive(family, "beta".to_owned(), weight, weight),
EvidenceEvent::positive(family, "alpha".to_owned(), weight, weight),
];
let r1 = reduce_family_polarity(&events_alpha_first, POSITIVE_SCHEDULE, PER_GROUP_CAP);
let r2 = reduce_family_polarity(&events_beta_first, POSITIVE_SCHEDULE, PER_GROUP_CAP);
assert!(approx_eq(r1, r2), "order-invariant: r1={r1}, r2={r2}");
let expected = weight * 1.0 + weight * 0.5;
assert!(approx_eq(r1, expected), "expected {expected}, got {r1}");
let attr1 = reduce_with_attribution(&events_alpha_first);
let alpha_idx = 0; let beta_idx = 1; assert!(
attr1.contributions[alpha_idx] > attr1.contributions[beta_idx],
"alpha (slot 0) must have larger contribution than beta (slot 1): alpha={}, beta={}",
attr1.contributions[alpha_idx],
attr1.contributions[beta_idx]
);
}
proptest! {
#[test]
fn reduce_all_invariant_under_permutation_with_real_ids(
seeds in prop::collection::vec(any::<u32>(), 0..16),
) {
let family = SignalFamily::General;
let weight = 0.2_f64;
let ids = ["tech-a", "tech-b", "tech-c", "tech-d"];
let events: Vec<EvidenceEvent> = ids
.iter()
.map(|id| EvidenceEvent::positive(family, id.to_owned(), weight, weight))
.collect();
let baseline = reduce_all(&events);
let perm = permute(events.len(), &seeds);
let permuted: Vec<EvidenceEvent> = perm.into_iter().map(|i| events[i].clone()).collect();
let permuted_result = reduce_all(&permuted);
prop_assert!(
(baseline - permuted_result).abs() < 1e-9,
"reduce_all must be order-invariant with real IDs: baseline={baseline}, permuted={permuted_result}"
);
}
}