From 27b61b379bbc47b65fbaceeebebff24b8551c2a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 15:53:17 +0000 Subject: [PATCH] feat(analysis): bind nested ICC of posterior coordinates to an analysis-run profile Cutoff-safe membership_posterior_icc_v1 classifies nested versus multiple-membership versus cross-classified designs without collapse, averages posterior draws without Rubin pooling, recovers nested ANOVA ICC only when nested, and still emits Kish ESS when nested ICC is refused. --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/analysis_engine/Cargo.toml | 3 +- crates/analysis_engine/src/lib.rs | 41 +- .../src/membership_posterior_icc_artifact.rs | 526 ++++++++++++++++++ ...ership_posterior_icc_execution_contract.rs | 370 ++++++++++++ docs/TRACEABILITY.md | 1 + ...5-membership-posterior-icc-analysis-run.md | 98 ++++ docs/adr/README.md | 1 + .../membership-posterior-icc-analysis-run.md | 19 + 10 files changed, 1059 insertions(+), 2 deletions(-) create mode 100644 crates/analysis_engine/src/membership_posterior_icc_artifact.rs create mode 100644 crates/analysis_engine/tests/membership_posterior_icc_execution_contract.rs create mode 100644 docs/adr/0045-membership-posterior-icc-analysis-run.md create mode 100644 docs/doctoring/membership-posterior-icc-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..9b7b751d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- **Membership-posterior ICC analysis-run profile**: cutoff-safe `membership_posterior_icc_v1` binds `posterior_draw_point_estimate_mean`, nested ANOVA ICC, and Kish ESS, and refuses nested ICC for multiple-membership and cross-classified designs (`analysis_engine`). Not MMMC and not implemented-main. - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/Cargo.lock b/Cargo.lock index 454a7d612..28a0f0cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "psychometric_core", "relation_graph", "serde", "serde_json", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..1f1c7ee40 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,8 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +membership_core = { path = "../membership_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -25,7 +27,6 @@ uuid.workspace = true [dev-dependencies] corpus_split = { path = "../corpus_split", version = "0.2.0" } -membership_core = { path = "../membership_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } [lints] diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..560c7d0ec 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,13 +8,18 @@ //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic //! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Membership-posterior ICC +//! composition is invoked through [`membership_core`] and [`psychometric_core`] +//! and is not an MMMC sampler. mod case_deletion_refit; mod lineage_criterion; +mod membership_posterior_icc_artifact; mod topic_context_posterior; mod topic_lineage_artifact; +use membership_core::MembershipError; +use psychometric_core::PsychometricError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -46,6 +51,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Membership-posterior ICC artifact and execution contracts. +pub use membership_posterior_icc_artifact::{ + MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT, MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION, + MEMBERSHIP_POSTERIOR_ICC_MODEL_CONTRACT_VERSION, MEMBERSHIP_POSTERIOR_ICC_OUTPUT_PROFILE, + MembershipPosteriorIccArtifact, MembershipPosteriorIccExecution, + MembershipPosteriorObservation, execute_membership_posterior_icc_run, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, @@ -248,6 +260,12 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A psychometric recovery rejected the offered coordinates. + Psychometric(PsychometricError), + /// A membership-network estimator rejected the offered design or weights. + Membership(MembershipError), + /// A membership-posterior ICC artifact violated its bounded schema or counts. + InvalidMembershipPosteriorIccArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +280,11 @@ impl fmt::Display for AnalysisEngineError { Self::LimitExceeded => "analysis corpus exceeded its execution bound", Self::TopicMeasurement(error) => return error.fmt(formatter), Self::InvalidTopicLineageArtifact => "invalid topic lineage artifact", + Self::Psychometric(error) => return error.fmt(formatter), + Self::Membership(error) => return error.fmt(formatter), + Self::InvalidMembershipPosteriorIccArtifact => { + "invalid membership-posterior ICC artifact" + } }; formatter.write_str(message) } @@ -281,6 +304,18 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: PsychometricError) -> Self { + Self::Psychometric(error) + } +} + +impl From for AnalysisEngineError { + fn from(error: MembershipError) -> Self { + Self::Membership(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -681,6 +716,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidMembershipPosteriorIccArtifact, + "invalid membership-posterior ICC artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/membership_posterior_icc_artifact.rs b/crates/analysis_engine/src/membership_posterior_icc_artifact.rs new file mode 100644 index 000000000..c153ba34f --- /dev/null +++ b/crates/analysis_engine/src/membership_posterior_icc_artifact.rs @@ -0,0 +1,526 @@ +//! Digest-bound nested ICC of posterior coordinates under classified membership. + +use membership_core::{ + GroupId, MemberId, MembershipAssignment, MembershipDesign, MembershipError, MembershipNetwork, + MembershipRole, MembershipWeight, NestedOutcome, classify_membership_design, + kish_effective_sample_size, nested_intraclass_correlation, +}; +use psychometric_core::posterior_draw_point_estimate_mean; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed membership-posterior ICC artifact. +pub const MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION: &str = + "tepp.membership_posterior_icc.v1"; +/// Model contract required by the membership-posterior ICC execution path. +pub const MEMBERSHIP_POSTERIOR_ICC_MODEL_CONTRACT_VERSION: &str = "membership_posterior_icc_v1"; +/// Analysis-run output profile required for a membership-posterior ICC artifact. +pub const MEMBERSHIP_POSTERIOR_ICC_OUTPUT_PROFILE: &str = "membership_posterior_icc_v1"; +/// Maximum canonical artifact JSON size. +pub const MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const NESTED_INFERENCE_STATUS: &str = "nested_icc_of_posterior_means_not_mmmc"; +const MULTIPLE_MEMBERSHIP_INFERENCE_STATUS: &str = + "multiple_membership_preserved_nested_icc_refused"; +const CROSS_CLASSIFIED_INFERENCE_STATUS: &str = "cross_classified_preserved_nested_icc_refused"; +const DESIGN_NESTED: &str = "nested"; +const DESIGN_MULTIPLE_MEMBERSHIP: &str = "multiple_membership"; +const DESIGN_CROSS_CLASSIFIED: &str = "cross_classified"; + +/// One posterior-draw member offered with a time-varying membership assignment. +#[derive(Clone, Debug, PartialEq)] +pub struct MembershipPosteriorObservation { + assignment: MembershipAssignment, + posterior_draws: Vec, + available_time: AvailableTime, +} + +impl MembershipPosteriorObservation { + /// Bind posterior draws to one membership assignment and availability clock. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when draws are empty or + /// non-finite. + pub fn new( + assignment: MembershipAssignment, + posterior_draws: Vec, + available_time: AvailableTime, + ) -> Result { + if posterior_draws.is_empty() || posterior_draws.iter().any(|value| !value.is_finite()) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + assignment, + posterior_draws, + available_time, + }) + } + + /// Return the opaque member identity. + #[must_use] + pub const fn member_id(&self) -> MemberId { + self.assignment.member_id() + } + + /// Return the opaque group identity. + #[must_use] + pub const fn group_id(&self) -> GroupId { + self.assignment.group_id() + } + + /// Return the membership role. + #[must_use] + pub const fn role(&self) -> MembershipRole { + self.assignment.role() + } + + /// Return the membership weight. + #[must_use] + pub const fn weight(&self) -> MembershipWeight { + self.assignment.weight() + } + + /// Return the posterior draws used for the point estimate. + #[must_use] + pub fn posterior_draws(&self) -> &[f64] { + &self.posterior_draws + } + + /// Return the availability clock used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded membership-posterior ICC composition for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MembershipPosteriorIccArtifact { + /// Exact versioned schema identity. + pub schema_version: String, + /// Opaque accepted-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical evidence cutoff used by the composition. + pub knowledge_cutoff: String, + /// Event-time instant at which membership design is classified. + pub classification_instant: String, + /// Classified membership design without collapsing multiple membership. + pub membership_design: String, + /// Distinct members admitted after cutoff and activity filters. + pub eligible_member_count: u64, + /// Membership assignments admitted after cutoff and activity filters. + pub eligible_assignment_count: u64, + /// Observations excluded because availability was after the cutoff. + pub excluded_after_cutoff_count: u64, + /// Nested ANOVA ICC of posterior means, or `null` when the design refuses it. + pub nested_icc: Option, + /// Kish effective sample size of admitted membership weights. + pub kish_ess: f64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl MembershipPosteriorIccArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidMembershipPosteriorIccArtifact`] + /// when the schema, identifiers, design, ICC, ESS, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidMembershipPosteriorIccArtifact)?; + artifact.validate()?; + Ok(artifact) + } + + /// Serialize canonical validated artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation, serialization, or size failure. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + if self.schema_version != MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || EventTime::parse_rfc3339(&self.classification_instant).is_err() + || self.eligible_member_count == 0 + || self.eligible_assignment_count == 0 + || !self.kish_ess.is_finite() + || self.kish_ess <= 0.0 + { + return Err(AnalysisEngineError::InvalidMembershipPosteriorIccArtifact); + } + let design_ok = match self.membership_design.as_str() { + DESIGN_NESTED => { + matches!(self.nested_icc, Some(value) if value.is_finite() && (0.0..=1.0).contains(&value)) + && self.inference_status == NESTED_INFERENCE_STATUS + } + DESIGN_MULTIPLE_MEMBERSHIP => { + self.nested_icc.is_none() + && self.inference_status == MULTIPLE_MEMBERSHIP_INFERENCE_STATUS + } + DESIGN_CROSS_CLASSIFIED => { + self.nested_icc.is_none() + && self.inference_status == CROSS_CLASSIFIED_INFERENCE_STATUS + } + _ => false, + }; + if !design_ok { + return Err(AnalysisEngineError::InvalidMembershipPosteriorIccArtifact); + } + Ok(()) + } +} + +/// One completed membership-posterior ICC artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct MembershipPosteriorIccExecution { + /// Digest-bound completed composition artifact. + pub artifact: MembershipPosteriorIccArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +struct EligibleMembership { + network: MembershipNetwork, + outcomes: Vec, + weights: Vec, + excluded_after_cutoff_count: u64, +} + +fn admit_observations_at_cutoff( + observations: &[MembershipPosteriorObservation], + knowledge_cutoff: KnowledgeCutoff, + classification_instant: EventTime, +) -> Result { + if observations.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + let mut network = MembershipNetwork::new(); + let mut outcomes = Vec::new(); + let mut weights = Vec::new(); + let mut excluded_after_cutoff_count = 0_u64; + for observation in observations { + if observation.available_time.instant() > knowledge_cutoff.instant() { + excluded_after_cutoff_count += 1; + continue; + } + if !observation.assignment.is_active_at(classification_instant) { + continue; + } + let point_estimate = posterior_draw_point_estimate_mean(&observation.posterior_draws)?; + network.insert(observation.assignment)?; + outcomes.push(NestedOutcome::new(observation.member_id(), point_estimate)?); + weights.push(observation.weight().value()); + } + if outcomes.is_empty() { + return Err(AnalysisEngineError::Membership( + MembershipError::InsufficientClusterStructure, + )); + } + Ok(EligibleMembership { + network, + outcomes, + weights, + excluded_after_cutoff_count, + }) +} + +fn design_wire_name( + design: MembershipDesign, +) -> Result<(&'static str, &'static str), AnalysisEngineError> { + match design { + MembershipDesign::Nested => Ok((DESIGN_NESTED, NESTED_INFERENCE_STATUS)), + MembershipDesign::MultipleMembership => Ok(( + DESIGN_MULTIPLE_MEMBERSHIP, + MULTIPLE_MEMBERSHIP_INFERENCE_STATUS, + )), + MembershipDesign::CrossClassified => { + Ok((DESIGN_CROSS_CLASSIFIED, CROSS_CLASSIFIED_INFERENCE_STATUS)) + } + _ => Err(AnalysisEngineError::InvalidEvidence), + } +} + +fn nested_icc_for_design( + design: MembershipDesign, + network: &MembershipNetwork, + instant: EventTime, + outcomes: &[NestedOutcome], +) -> Result, AnalysisEngineError> { + match design { + MembershipDesign::Nested => Ok(Some(nested_intraclass_correlation( + network, instant, outcomes, + )?)), + MembershipDesign::MultipleMembership | MembershipDesign::CrossClassified => Ok(None), + _ => Err(AnalysisEngineError::InvalidEvidence), + } +} + +/// Execute cutoff-safe nested ICC of posterior means under classified membership. +/// +/// Point estimates use [`posterior_draw_point_estimate_mean`], not Rubin pooling. +/// Multiple membership and cross-classification are classified without collapse; +/// nested ICC is refused for those designs while Kish ESS of membership weights +/// is still emitted. This is not ESEM, not DSEM, and not an MMMC sampler. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, psychometric or +/// membership recovery failure, or invalid artifact error. +pub fn execute_membership_posterior_icc_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + classification_instant: EventTime, + observations: &[MembershipPosteriorObservation], + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != MEMBERSHIP_POSTERIOR_ICC_MODEL_CONTRACT_VERSION + || request.output_profile != MEMBERSHIP_POSTERIOR_ICC_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let eligible = + admit_observations_at_cutoff(observations, knowledge_cutoff, classification_instant)?; + let design = classify_membership_design(&eligible.network, classification_instant)?; + let (membership_design, inference_status) = design_wire_name(design)?; + let nested_icc = nested_icc_for_design( + design, + &eligible.network, + classification_instant, + &eligible.outcomes, + )?; + let kish_ess = kish_effective_sample_size(&eligible.weights)?; + let eligible_assignment_count = u64::try_from(eligible.weights.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let eligible_member_count = u64::try_from(eligible.outcomes.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + + let artifact = MembershipPosteriorIccArtifact { + schema_version: MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + classification_instant: classification_instant.to_rfc3339(), + membership_design: membership_design.into(), + eligible_member_count, + eligible_assignment_count, + excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, + nested_icc, + kish_ess, + inference_status: inference_status.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "membership_posterior_icc", + eligible_assignment_count, + 2, + inference_status, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("membership_posterior_icc_artifact_{}", &digest[..16]), + digest, + MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(MembershipPosteriorIccExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + CROSS_CLASSIFIED_INFERENCE_STATUS, DESIGN_CROSS_CLASSIFIED, DESIGN_NESTED, + MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT, + MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION, MULTIPLE_MEMBERSHIP_INFERENCE_STATUS, + MembershipPosteriorIccArtifact, NESTED_INFERENCE_STATUS, + }; + use crate::AnalysisEngineError; + + fn nested_artifact() -> MembershipPosteriorIccArtifact { + MembershipPosteriorIccArtifact { + schema_version: MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + classification_instant: "2026-06-01T00:00:00Z".into(), + membership_design: DESIGN_NESTED.into(), + eligible_member_count: 8, + eligible_assignment_count: 8, + excluded_after_cutoff_count: 0, + nested_icc: Some(0.25), + kish_ess: 8.0, + inference_status: NESTED_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &MembershipPosteriorIccArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidMembershipPosteriorIccArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = nested_artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + MembershipPosteriorIccArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + MembershipPosteriorIccArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidMembershipPosteriorIccArtifact) + ); + assert_eq!( + MembershipPosteriorIccArtifact::from_json( + &"x".repeat(MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_BYTE_LIMIT + 1) + ), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_metadata_tampering_fails_closed() { + let artifact = nested_artifact(); + let invalid_artifacts = [ + { + let mut value = artifact.clone(); + value.schema_version.clear(); + value + }, + { + let mut value = artifact.clone(); + value.run_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.snapshot_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.knowledge_cutoff = "invalid".into(); + value + }, + { + let mut value = artifact.clone(); + value.classification_instant = "invalid".into(); + value + }, + { + let mut value = artifact.clone(); + value.membership_design = "collapsed".into(); + value + }, + { + let mut value = artifact.clone(); + value.eligible_member_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.eligible_assignment_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.nested_icc = None; + value + }, + { + let mut value = artifact.clone(); + value.nested_icc = Some(1.5); + value + }, + { + let mut value = artifact.clone(); + value.nested_icc = Some(f64::NAN); + value + }, + { + let mut value = artifact.clone(); + value.kish_ess = 0.0; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + { + let mut value = artifact.clone(); + value.membership_design = DESIGN_CROSS_CLASSIFIED.into(); + value.nested_icc = Some(0.25); + value.inference_status = CROSS_CLASSIFIED_INFERENCE_STATUS.into(); + value + }, + { + let mut value = artifact.clone(); + value.membership_design = "multiple_membership".into(); + value.nested_icc = None; + value.inference_status = MULTIPLE_MEMBERSHIP_INFERENCE_STATUS.into(); + value.kish_ess = f64::INFINITY; + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/tests/membership_posterior_icc_execution_contract.rs b/crates/analysis_engine/tests/membership_posterior_icc_execution_contract.rs new file mode 100644 index 000000000..f128adf31 --- /dev/null +++ b/crates/analysis_engine/tests/membership_posterior_icc_execution_contract.rs @@ -0,0 +1,370 @@ +//! End-to-end contract for cutoff-safe membership-posterior ICC composition. + +use analysis_engine::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION, + MEMBERSHIP_POSTERIOR_ICC_MODEL_CONTRACT_VERSION, MEMBERSHIP_POSTERIOR_ICC_OUTPUT_PROFILE, + MembershipPosteriorObservation, execute_membership_posterior_icc_run, +}; +use membership_core::{ + GroupId, MemberId, MembershipAssignment, MembershipError, MembershipRole, MembershipWeight, +}; +use psychometric_core::PsychometricError; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn event(stamp: &str) -> EventTime { + EventTime::parse_rfc3339(stamp).expect("event") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn classification() -> EventTime { + event("2026-06-01T00:00:00Z") +} + +fn start() -> EventTime { + event("2026-01-01T00:00:00Z") +} + +fn end() -> EventTime { + event("2026-12-31T00:00:00Z") +} + +fn observation( + member: MemberId, + group: GroupId, + role: MembershipRole, + weight: f64, + draws: Vec, + available_stamp: &str, +) -> MembershipPosteriorObservation { + let assignment = MembershipAssignment::new( + member, + group, + role, + MembershipWeight::new(weight).expect("weight"), + start(), + end(), + ) + .expect("assignment"); + MembershipPosteriorObservation::new(assignment, draws, available(available_stamp)) + .expect("observation") +} + +fn nested_observations() -> Vec { + // Four groups of two: means 2,3,4,5 with within deviation ±1 → ICC = 1/4. + // Posterior draws average to those known outcomes; this is not Rubin pooling. + let groups = [ + GroupId::new(), + GroupId::new(), + GroupId::new(), + GroupId::new(), + ]; + let rows = [ + (groups[0], [1.0, 3.0]), + (groups[1], [2.0, 4.0]), + (groups[2], [3.0, 5.0]), + (groups[3], [4.0, 6.0]), + ]; + let mut observations = Vec::new(); + for (group, values) in rows { + for value in values { + observations.push(observation( + MemberId::new(), + group, + MembershipRole::Author, + 1.0, + vec![value - 1.0, value + 1.0], + "2026-07-01T00:00:00Z", + )); + } + } + observations +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "membership-posterior-icc-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-membership-posterior-icc".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: MEMBERSHIP_POSTERIOR_ICC_MODEL_CONTRACT_VERSION.into(), + output_profile: MEMBERSHIP_POSTERIOR_ICC_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-membership-posterior-icc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, + observations: &[MembershipPosteriorObservation], +) -> Result { + execute_membership_posterior_icc_run( + request, + &accepted(request), + "snapshot-membership-posterior-icc", + cutoff(), + classification(), + observations, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn nested_posterior_means_recover_known_anova_icc_and_kish_ess() { + let request = request(); + let observations = nested_observations(); + let execution = execute(&request, &observations).expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.membership_design, "nested"); + assert_eq!(execution.artifact.eligible_member_count, 8); + assert_eq!(execution.artifact.eligible_assignment_count, 8); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); + let nested_icc = execution.artifact.nested_icc.expect("nested icc"); + assert!((nested_icc - 0.25).abs() < 1e-12); + assert!((execution.artifact.kish_ess - 8.0).abs() < 1e-12); + assert_eq!( + execution.artifact.inference_status, + "nested_icc_of_posterior_means_not_mmmc" + ); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution.terminal_result.result_sha256.as_deref(), + Some(execution.artifact.sha256().expect("digest").as_str()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(MEMBERSHIP_POSTERIOR_ICC_ARTIFACT_SCHEMA_VERSION) + ); + assert_eq!(observations[0].role(), MembershipRole::Author); + assert!((observations[0].weight().value() - 1.0).abs() < f64::EPSILON); + assert_eq!( + observations[0].available_time(), + available("2026-07-01T00:00:00Z") + ); + assert_eq!(observations[0].posterior_draws().len(), 2); +} + +#[test] +fn execution_excludes_observations_unavailable_at_the_request_cutoff() { + let request = request(); + let mut observations = nested_observations(); + observations.push(observation( + MemberId::new(), + GroupId::new(), + MembershipRole::Author, + 1.0, + vec![9.0, 11.0], + "2026-08-15T00:00:00Z", + )); + let execution = execute(&request, &observations).expect("execution"); + assert_eq!(execution.artifact.eligible_member_count, 8); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); + let nested_icc = execution.artifact.nested_icc.expect("nested icc"); + assert!((nested_icc - 0.25).abs() < 1e-12); +} + +#[test] +fn multiple_membership_preserves_design_refuses_nested_icc_and_emits_kish_ess() { + let request = request(); + let member = MemberId::new(); + let observations = vec![ + observation( + member, + GroupId::new(), + MembershipRole::Department, + 0.6, + vec![1.0, 3.0], + "2026-07-01T00:00:00Z", + ), + observation( + member, + GroupId::new(), + MembershipRole::Department, + 0.4, + vec![0.0, 2.0], + "2026-07-01T00:00:00Z", + ), + ]; + let execution = execute(&request, &observations).expect("execution"); + assert_eq!(execution.artifact.membership_design, "multiple_membership"); + assert_eq!(execution.artifact.nested_icc, None); + assert_eq!(execution.artifact.eligible_assignment_count, 2); + let expected_ess = 1.0 / 0.52; + assert!((execution.artifact.kish_ess - expected_ess).abs() < 1e-12); + assert_eq!( + execution.artifact.inference_status, + "multiple_membership_preserved_nested_icc_refused" + ); +} + +#[test] +fn cross_classified_preserves_design_and_refuses_nested_icc() { + let request = request(); + let member = MemberId::new(); + let observations = vec![ + observation( + member, + GroupId::new(), + MembershipRole::Author, + 1.0, + vec![1.0, 1.0], + "2026-07-01T00:00:00Z", + ), + observation( + member, + GroupId::new(), + MembershipRole::Project, + 0.5, + vec![2.0, 2.0], + "2026-07-01T00:00:00Z", + ), + ]; + let execution = execute(&request, &observations).expect("execution"); + assert_eq!(execution.artifact.membership_design, "cross_classified"); + assert_eq!(execution.artifact.nested_icc, None); + assert_eq!( + execution.artifact.inference_status, + "cross_classified_preserved_nested_icc_refused" + ); + assert!(execution.artifact.kish_ess > 1.0); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let observations = nested_observations(); + assert_eq!( + execute_membership_posterior_icc_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + classification(), + &observations, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "other-profile".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request, &observations), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn execution_refuses_non_finite_draws_empty_eligibility_and_oversize() { + assert_eq!( + MembershipPosteriorObservation::new( + MembershipAssignment::new( + MemberId::new(), + GroupId::new(), + MembershipRole::Author, + MembershipWeight::full().expect("full"), + start(), + end(), + ) + .expect("assignment"), + vec![f64::NAN], + available("2026-07-01T00:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + MembershipPosteriorObservation::new( + MembershipAssignment::new( + MemberId::new(), + GroupId::new(), + MembershipRole::Author, + MembershipWeight::full().expect("full"), + start(), + end(), + ) + .expect("assignment"), + vec![], + available("2026-07-01T00:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let request = request(); + let late_only = vec![observation( + MemberId::new(), + GroupId::new(), + MembershipRole::Author, + 1.0, + vec![1.0, 3.0], + "2026-08-15T00:00:00Z", + )]; + assert_eq!( + execute(&request, &late_only), + Err(AnalysisEngineError::Membership( + MembershipError::InsufficientClusterStructure + )) + ); + let pad_member = MemberId::new(); + let pad_group = GroupId::new(); + let oversized = vec![ + observation( + pad_member, + pad_group, + MembershipRole::Author, + 1.0, + vec![1.0, 1.0], + "2026-07-01T00:00:00Z", + ); + MAX_EVIDENCE_UNITS + 1 + ]; + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} + +#[test] +fn posterior_mean_is_not_rubin_pooling_and_psychometric_errors_surface() { + let mean = psychometric_core::posterior_draw_point_estimate_mean(&[1.0, 3.0]).expect("mean"); + assert!((mean - 2.0).abs() < 1e-15); + assert_eq!( + psychometric_core::posterior_draw_point_estimate_mean(&[]), + Err(PsychometricError::InvalidNumericInput) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..600fa7386 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); LineageWeave loopback contracts and request-bound terminal result are composed on the active product branch; production TLS remaining | partial | | executable cutoff-safe analysis runs | ADR 0012/0022; temporal research; API terminal-result contract | `analysis_engine` availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound readiness artifact, and `tepp.trsl_topic_lineage.v1` execution through `topic_measurement`; synthetic recovery plus tamper/non-convergence tests and exact coverage on the active product branch | active-PR | +| membership-posterior ICC analysis-run composition | ADR 0003/0005/0022/0045 | `analysis_engine` `membership_posterior_icc_v1` binds posterior-mean point estimates, nested ICC, and Kish ESS without collapsing multiple membership; not MMMC and not implemented-main | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004; ADR 0020 | `semantic_core` span-grounded units (active-PR); concept dictionary and shared latent estimator remaining | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | `topic_measurement` stable ALR/ILR coordinates and bounded CPU `f64` reference estimator on protected main; `model_selection` fitted candidate-`K` scoring on this PR; calibrated posterior promotion, method effects, persistence, and accelerated backends remaining | partial | diff --git a/docs/adr/0045-membership-posterior-icc-analysis-run.md b/docs/adr/0045-membership-posterior-icc-analysis-run.md new file mode 100644 index 000000000..a858ad1f3 --- /dev/null +++ b/docs/adr/0045-membership-posterior-icc-analysis-run.md @@ -0,0 +1,98 @@ +# ADR 0045 — Nested ICC of posterior coordinates under classified membership + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0003 (multiple membership), ADR 0005 (posterior coordinates), and ADR 0022 (cutoff-safe analysis-run execution). +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already classifies nested, cross-classified, and multiple-membership +designs inside `membership_core`, recovers a nested ANOVA ICC only for nested +designs, and computes Kish ESS of membership weights. `psychometric_core` +already averages finite posterior-draw point estimates without Rubin pooling. +Operators still cannot request that composition as a digest-bound analysis-run +output. Recovery primitives alone are not an MMMC sampler and are not the +ESEM/DSEM engine (GAP-006 / #169). A second Driver p.16 `std`-family restore, +Leiden consensus, GAP-003A scientific-acceptance wiring, Compose persistence, +CWC slopes, Rubin loading uncertainty, longitudinal ESEM/DSEM collapse gating, +two-group OLS invariance, or irregular event-time log-rate would not close this +operator-visible gap. + +Collapsing multiple membership into a nested ICC is the atomistic fallacy. +Rubin `T` is a different estimand already bound on a live analysis-run profile. + +## Decision + +Add the `membership_posterior_icc_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes posterior-draw observations bound to a membership assignment and + `available_time`; +- excludes observations whose availability is later than the request + `knowledge_cutoff`; +- forms point estimates through `posterior_draw_point_estimate_mean` and does + not invoke Rubin pooling; +- inserts admitted assignments into `MembershipNetwork` and classifies the + design at a caller-supplied event-time instant without collapsing structure; +- invokes `nested_intraclass_correlation` only for `MembershipDesign::Nested`; +- refuses nested ICC for multiple-membership and cross-classified designs while + still emitting Kish ESS of admitted membership weights; +- emits a canonical SHA-256-digested `tepp.membership_posterior_icc.v1` artifact + with design, eligible counts, optional nested ICC, Kish ESS, and an inference + status that names the claim boundary; +- does not invent an MMMC sampler, persist rows, or claim ESEM/DSEM. + +## Alternatives considered + +1. Restore another Driver p.16 standardised matrix — rejected because those + recoveries are already a live micro-PR family and do not bind membership + design to an analysis run. +2. Bind Kish-weighted CWC (#312) — rejected because that psychometric expose is + already a live draft and is a different estimand. +3. Reuse the ESEM/DSEM membership-design collapse gate (#376) — rejected because + that profile treats design as an ESEM/DSEM admission gate and does not emit + nested ICC or Kish ESS of posterior-draw membership weights. +4. Bind Rubin loading uncertainty (#374) — rejected because Rubin `T` is not + the posterior-mean point estimate used here. +5. Put membership ICC into `tepp_api` — rejected because transport contracts + and scientific composition would become one service boundary. +6. Bind existing `membership_core` nested ICC plus Kish ESS and + `psychometric_core` posterior means to ADR 0022's analysis-run profile — + accepted. + +## Consequences + +Operators can request cutoff-safe nested ICC of posterior means when membership +is nested, and still receive Kish ESS when membership is multiple or +cross-classified, without collapsing those designs. The engine does not expose +source text or identity mappings. Nested ICC remains undefined for MMMC; the +artifact records the refusal instead of substituting a nested number. + +Cutoff exclusion, snapshot/profile mismatch, empty eligibility, invalid draws, +duplicate assignments, and oversize corpora fail closed. + +## Verification + +The PR includes Rust unit and integration tests for nested ANOVA recovery of +posterior means, cutoff exclusion, multiple-membership and cross-classified +refusal of nested ICC with Kish ESS, snapshot/profile mismatch, empty +eligibility, non-finite draws, oversize corpora, and artifact tampering. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +Exact-head Checks and two independent approvals are required before any +implemented-main claim. + +## Rollback and supersession + +Rollback removes the `membership_posterior_icc_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps nested ICC +distinct from multiple-membership/cross-classified designs and keeps posterior +means distinct from Rubin pooling. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..88399ee67 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [0045](0045-membership-posterior-icc-analysis-run.md) | Nested ICC of posterior coordinates under classified membership | Accepted | active-PR | Complements ADR 0003/0005/0022; nested ICC of posterior means, Kish ESS, and fail-closed MM/cross-classified refusal. Not MMMC. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/doctoring/membership-posterior-icc-analysis-run.md b/docs/doctoring/membership-posterior-icc-analysis-run.md new file mode 100644 index 000000000..b8816197c --- /dev/null +++ b/docs/doctoring/membership-posterior-icc-analysis-run.md @@ -0,0 +1,19 @@ +# Membership-posterior ICC analysis-run composition + +**Active slice:** ADR 0045 / `membership_posterior_icc_v1` +**Protected-main status:** not implemented-main + +`membership_core` already classifies nested versus multiple-membership versus +cross-classified designs, recovers nested ANOVA ICC only when nested, and +computes Kish ESS of membership weights. `psychometric_core` already averages +posterior-draw point estimates without Rubin pooling. This slice binds those +recoveries to a cutoff-safe analysis-run profile so an operator can request a +digest-bound terminal result. + +Multiple membership is classified and preserved. Nested ICC is refused for +multiple-membership and cross-classified designs; Kish ESS is still emitted. +The profile is not ESEM, not DSEM, not Rubin `T`, not CWC, and not an MMMC +sampler. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim.