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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang
- `tepp_simulation` deterministic truth-corpus generator with delayed reporting, multilevel memberships, method-effect variants, relation noise, and digest-bound truth manifests.
- `corpus_split` leakage-safe knowledge-cutoff snapshots, relation-connected co-partition groups, rolling-origin windows, and group-normalized ESS weight contracts.
- `persistence_postgres` live SQL port: `SqlSession` transport, migration batch applicator, document/audit SQL contracts, `LiveDocumentRepository`, and fail-closed `DATABASE_URL`/`LiveSqlxConfig` gate for SQLx pool wiring (live pool/query driver remains accepted-target).
- `membership_core` Kish effective sample size, design effect, and group-normalized ESS helpers for multiple-membership estimation inputs.
- `persistence_postgres` bitemporal foundation: multi-word migration contracts, knowledge-cutoff eligibility, and in-memory as-known-at / as-valid-at document replay (live SQLx/PostgreSQL execution remains accepted-target).
- `event_core` mention/instance separation with explicit promotion, typed roles, event-time validity, and fail-closed mention-as-instance refusal.
- `membership_core` time-varying weighted multiple-membership network with contextual roles, event-time validity, and atomistic-fallacy prevention contracts.
Expand Down
156 changes: 156 additions & 0 deletions crates/membership_core/src/ess.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Effective sample size and design-effect helpers for multiple membership.

use crate::MembershipError;

/// Kish effective sample size for a set of non-negative finite weights.
///
/// `ESS = (Σ w)² / Σ w²`. Empty input fails closed. A single positive weight
/// yields ESS `1.0`. Zero total weight fails closed (no information).
///
/// # Errors
///
/// Returns [`MembershipError::InvalidMembershipWeight`] for empty input, any
/// non-finite or negative weight, or a zero total weight.
pub fn kish_effective_sample_size(weights: &[f64]) -> Result<f64, MembershipError> {
if weights.is_empty() {
return Err(MembershipError::InvalidMembershipWeight);
}
let mut sum = 0.0;
let mut sum_sq = 0.0;
for &weight in weights {
let finite = weight.is_finite();
let non_negative = weight >= 0.0;
if !finite {
return Err(MembershipError::InvalidMembershipWeight);
}
if !non_negative {
return Err(MembershipError::InvalidMembershipWeight);
}
sum += weight;
sum_sq += weight * weight;
}
if sum <= 0.0 {
return Err(MembershipError::InvalidMembershipWeight);
}
// With finite non-negative weights and positive sum, sum_sq is positive.
Ok((sum * sum) / sum_sq)
}

/// Design effect `n / ESS` for the same weight vector.
///
/// Values above `1.0` indicate inflation of variance relative to an equal-weight
/// sample of size `n = weights.len()`.
///
/// # Errors
///
/// Propagates [`kish_effective_sample_size`] failures.
pub fn design_effect(weights: &[f64]) -> Result<f64, MembershipError> {
let ess = kish_effective_sample_size(weights)?;
#[allow(clippy::cast_precision_loss)]
let n = weights.len() as f64;
Ok(n / ess)
}

/// Group-normalize weights so each group's weights sum to one, then return Kish
/// ESS over the concatenated normalized weights.
///
/// Used when co-partitioned groups must not dominate recovery or split ESS by
/// raw headcount.
///
/// # Errors
///
/// Returns [`MembershipError::InvalidMembershipWeight`] when any group is empty,
/// contains invalid weights, or has zero total weight.
pub fn group_normalized_kish_ess(groups: &[Vec<f64>]) -> Result<f64, MembershipError> {
if groups.is_empty() {
return Err(MembershipError::InvalidMembershipWeight);
}
let mut normalized = Vec::new();
for group in groups {
if group.is_empty() {
return Err(MembershipError::InvalidMembershipWeight);
}
let mut sum = 0.0;
for &weight in group {
let finite = weight.is_finite();
let non_negative = weight >= 0.0;
if !finite {
return Err(MembershipError::InvalidMembershipWeight);
}
if !non_negative {
return Err(MembershipError::InvalidMembershipWeight);
}
sum += weight;
}
if sum <= 0.0 {
return Err(MembershipError::InvalidMembershipWeight);
}
for &weight in group {
normalized.push(weight / sum);
}
}
kish_effective_sample_size(&normalized)
}

#[cfg(test)]
mod tests {
use super::{design_effect, group_normalized_kish_ess, kish_effective_sample_size};
use crate::MembershipError;

#[test]
fn kish_ess_and_design_effect_oracle_cases() {
assert!(
(kish_effective_sample_size(&[1.0, 1.0, 1.0, 1.0]).expect("eq") - 4.0).abs() < 1e-12
);
assert!((design_effect(&[1.0, 1.0, 1.0, 1.0]).expect("de") - 1.0).abs() < 1e-12);
let unequal = kish_effective_sample_size(&[1.0, 0.0, 0.0, 0.0]).expect("one");
assert!((unequal - 1.0).abs() < 1e-12);
assert!(design_effect(&[1.0, 0.0, 0.0, 0.0]).expect("de2") > 1.0);

assert_eq!(
kish_effective_sample_size(&[]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
kish_effective_sample_size(&[f64::NAN]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
kish_effective_sample_size(&[-0.1]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
kish_effective_sample_size(&[0.0, 0.0]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
design_effect(&[f64::INFINITY]),
Err(MembershipError::InvalidMembershipWeight)
);

let groups = vec![vec![2.0, 2.0], vec![1.0]];
let ess = group_normalized_kish_ess(&groups).expect("g");
// normalized: 0.5,0.5,1.0 → sum=2, sum_sq=0.25+0.25+1=1.5 → ess=4/1.5
assert!((ess - (4.0 / 1.5)).abs() < 1e-12);
assert_eq!(
group_normalized_kish_ess(&[]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
group_normalized_kish_ess(&[vec![]]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
group_normalized_kish_ess(&[vec![0.0, 0.0]]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
group_normalized_kish_ess(&[vec![-1.0]]),
Err(MembershipError::InvalidMembershipWeight)
);
assert_eq!(
group_normalized_kish_ess(&[vec![f64::NAN]]),
Err(MembershipError::InvalidMembershipWeight)
);
}
}
9 changes: 9 additions & 0 deletions crates/membership_core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![allow(clippy::cast_precision_loss)]
//! Time-varying cross-classified and multiple-membership assignments.
//!
//! TEPP models documents and other observations as members of many simultaneous
Expand All @@ -11,6 +12,7 @@

mod assignment;
mod error;
mod ess;
mod identifier;
mod network;
mod role;
Expand All @@ -30,3 +32,10 @@ pub use network::MembershipNetwork;
pub use role::MembershipRole;
/// Finite non-negative membership weight.
pub use weight::MembershipWeight;

/// Design effect `n / ESS` for membership weights.
pub use ess::design_effect;
/// Group-normalized Kish ESS for co-partitioned membership weights.
pub use ess::group_normalized_kish_ess;
/// Kish effective sample size for membership weights.
pub use ess::kish_effective_sample_size;
16 changes: 16 additions & 0 deletions docs/research/membership-ess-design-effect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Membership ESS and design effect

## Scope

Adds Kish effective sample size and design-effect helpers for weighted multiple membership, plus group-normalized ESS for co-partitioned groups. These pure CPU `f64` functions feed multilevel estimators and split-weight diagnostics without collapsing multiple membership into a single hierarchy.

## Authority

Kish, L. (1965). *Survey sampling*. John Wiley & Sons.

Browne, W. J., Goldstein, H., & Rasbash, J. (2001). Multiple membership multiple classification (MMMC) models. *Statistical Modelling, 1*(2), 103–124. https://doi.org/10.1177/1471082X0100100202

## Verification

- unit oracle tests for equal weights, single-positive weights, invalid inputs, and group-normalized ESS;
- workspace coverage gates remain complete.
Loading