From 3c8554e488a63723797268049280f85f5db1cfe9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:54:45 +0900 Subject: [PATCH] feat(membership): recover nested ICC and refuse cross-classified collapse Add a CPU f64 unbalanced ANOVA ICC for nested membership designs and fail closed when the active design is cross-classified or multiple-membership. No new migration (0007 remains owned by #45). --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + crates/membership_core/src/error.rs | 35 +++ crates/membership_core/src/icc.rs | 246 +++++++++++++++ crates/membership_core/src/lib.rs | 9 + .../tests/nested_icc_recovery_contract.rs | 293 ++++++++++++++++++ docs/TRACEABILITY.md | 2 +- ...03-relational-event-multiple-membership.md | 2 +- docs/adr/README.md | 2 +- .../nested-icc-cross-classified-refusal.md | 32 ++ docs/research/standards-and-literature.md | 10 + docs/validation/temporal-event-foundation.md | 2 +- 12 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 crates/membership_core/src/icc.rs create mode 100644 crates/membership_core/tests/nested_icc_recovery_contract.rs create mode 100644 docs/research/nested-icc-cross-classified-refusal.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db..e4c158ed 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ boundaries above remain the target modular MSA architecture. | `temporal_core` | typed clocks, intervals, and temporal reasoning | | `event_core` | event instances, mentions, roles, and provenance | | `relation_graph` | typed relations and forward-transition validation | -| `membership_core` | time-varying cross-classified multiple membership | +| `membership_core` | time-varying cross-classified multiple membership, Kish ESS, nested ICC with non-nested refusal | | `persistence_postgres` | PostgreSQL repositories and migrations | | `corpus_split` | cutoff-safe, relation-aware partitioning | | `tepp_simulation` | known-truth temporal/event data generation | diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cc6e87..3f5c0d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `membership_core` nested ICC: CPU `f64` unbalanced ANOVA recovers a known cluster ICC and refuses to treat cross-classified or multiple-membership designs as a single hierarchy (ADR 0003). - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/crates/membership_core/src/error.rs b/crates/membership_core/src/error.rs index d7ee9521..1dde2858 100644 --- a/crates/membership_core/src/error.rs +++ b/crates/membership_core/src/error.rs @@ -19,6 +19,16 @@ pub enum MembershipError { UnknownMembershipRole, /// A duplicate assignment key was rejected by the membership network. DuplicateMembershipAssignment, + /// Nested ICC is undefined for cross-classified or multiple-membership designs. + NestedIccInapplicable, + /// Clusters or within-group residual degrees of freedom are insufficient. + InsufficientClusterStructure, + /// An outcome value was non-finite. + InvalidOutcome, + /// The same member contributed more than one nested ICC outcome. + DuplicateOutcomeMember, + /// An outcome member has no active nested membership at the requested time. + UnknownOutcomeMember, } impl fmt::Display for MembershipError { @@ -30,6 +40,11 @@ impl fmt::Display for MembershipError { Self::UnsupportedWireVersion => "unsupported membership wire version", Self::UnknownMembershipRole => "unknown membership role", Self::DuplicateMembershipAssignment => "duplicate membership assignment", + Self::NestedIccInapplicable => "nested ICC is inapplicable to this membership design", + Self::InsufficientClusterStructure => "insufficient cluster structure for nested ICC", + Self::InvalidOutcome => "invalid nested ICC outcome", + Self::DuplicateOutcomeMember => "duplicate nested ICC outcome member", + Self::UnknownOutcomeMember => "unknown nested ICC outcome member", }; formatter.write_str(message) } @@ -68,6 +83,26 @@ mod tests { MembershipError::DuplicateMembershipAssignment, "duplicate membership assignment", ), + ( + MembershipError::NestedIccInapplicable, + "nested ICC is inapplicable to this membership design", + ), + ( + MembershipError::InsufficientClusterStructure, + "insufficient cluster structure for nested ICC", + ), + ( + MembershipError::InvalidOutcome, + "invalid nested ICC outcome", + ), + ( + MembershipError::DuplicateOutcomeMember, + "duplicate nested ICC outcome member", + ), + ( + MembershipError::UnknownOutcomeMember, + "unknown nested ICC outcome member", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/membership_core/src/icc.rs b/crates/membership_core/src/icc.rs new file mode 100644 index 00000000..01307956 --- /dev/null +++ b/crates/membership_core/src/icc.rs @@ -0,0 +1,246 @@ +//! Nested ICC with fail-closed cross-classified and multiple-membership gates. + +use crate::{MemberId, MembershipError, MembershipNetwork, MembershipRole}; +use std::collections::{BTreeMap, BTreeSet}; +use temporal_core::EventTime; + +/// Membership design implied by active assignments at one event time. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum MembershipDesign { + /// Each active member belongs to exactly one group in one role. + Nested, + /// At least one member is active in two or more roles. + CrossClassified, + /// At least one member is active in two or more groups of the same role. + MultipleMembership, +} + +impl MembershipDesign { + /// Return whether a one-way nested ICC is identified for this design. + #[must_use] + pub const fn allows_nested_icc(self) -> bool { + match self { + Self::Nested => true, + Self::CrossClassified | Self::MultipleMembership => false, + } + } +} + +/// One finite outcome attached to an opaque member for nested ICC recovery. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NestedOutcome { + member_id: MemberId, + value: f64, +} + +impl NestedOutcome { + /// Construct a finite nested ICC outcome. + /// + /// # Errors + /// + /// Returns [`MembershipError::InvalidOutcome`] when `value` is not finite. + pub fn new(member_id: MemberId, value: f64) -> Result { + if value.is_finite() { + Ok(Self { member_id, value }) + } else { + Err(MembershipError::InvalidOutcome) + } + } + + /// Return the outcome member. + #[must_use] + pub const fn member_id(self) -> MemberId { + self.member_id + } + + /// Return the finite outcome value. + #[must_use] + pub const fn value(self) -> f64 { + self.value + } +} + +/// Classify active memberships at `instant` without collapsing structure. +/// +/// Multiple membership is reported before cross-classification so a member +/// who occupies two groups in one role is not misread as a nested hierarchy. +/// +/// # Errors +/// +/// Returns [`MembershipError::InsufficientClusterStructure`] when no assignment +/// is active at `instant`. +pub fn classify_membership_design( + network: &MembershipNetwork, + instant: EventTime, +) -> Result { + let mut members = BTreeSet::new(); + for assignment in network.assignments() { + members.insert(assignment.member_id()); + } + classify_members(network, instant, members.iter().copied()) +} + +/// Recover the one-way random-intercept ICC for a nested membership design. +/// +/// The CPU `f64` estimator is the unbalanced ANOVA (Snijders & Bosker) +/// estimator +/// `σ²_u / (σ²_u + σ²_e)` with +/// `σ²_e = MSW` and `σ²_u = max(0, (MSB − MSW) / n₀)`. +/// Cross-classified and multiple-membership designs fail closed: a nested +/// ICC is not a substitute for an MMMC model. +/// +/// # Errors +/// +/// Returns a membership error when outcomes are empty, duplicated, unknown, +/// inactive, non-nested, or when cluster residual structure is insufficient. +pub fn nested_intraclass_correlation( + network: &MembershipNetwork, + instant: EventTime, + outcomes: &[NestedOutcome], +) -> Result { + if outcomes.is_empty() { + return Err(MembershipError::InsufficientClusterStructure); + } + let mut seen = BTreeSet::new(); + let mut groups: BTreeMap> = BTreeMap::new(); + let mut outcome_members = BTreeSet::new(); + for outcome in outcomes { + if !seen.insert(outcome.member_id()) { + return Err(MembershipError::DuplicateOutcomeMember); + } + let active = network.active_memberships_for(outcome.member_id(), instant); + if active.is_empty() { + return Err(MembershipError::UnknownOutcomeMember); + } + outcome_members.insert(outcome.member_id()); + let group = active[0].group_id(); + groups.entry(group).or_default().push(outcome.value()); + } + match classify_members(network, instant, outcome_members.iter().copied())? { + MembershipDesign::Nested => {} + MembershipDesign::CrossClassified | MembershipDesign::MultipleMembership => { + return Err(MembershipError::NestedIccInapplicable); + } + } + anova_nested_icc(&groups) +} + +fn classify_members( + network: &MembershipNetwork, + instant: EventTime, + members: I, +) -> Result +where + I: IntoIterator, +{ + let mut saw_active = false; + let mut saw_cross = false; + for member_id in members { + let active = network.active_memberships_for(member_id, instant); + if active.is_empty() { + continue; + } + saw_active = true; + let mut groups_by_role: BTreeMap> = + BTreeMap::new(); + for assignment in active { + groups_by_role + .entry(assignment.role()) + .or_default() + .insert(assignment.group_id()); + } + for groups in groups_by_role.values() { + if groups.len() >= 2 { + return Ok(MembershipDesign::MultipleMembership); + } + } + if groups_by_role.len() >= 2 { + saw_cross = true; + } + } + if !saw_active { + return Err(MembershipError::InsufficientClusterStructure); + } + if saw_cross { + Ok(MembershipDesign::CrossClassified) + } else { + Ok(MembershipDesign::Nested) + } +} + +fn anova_nested_icc(groups: &BTreeMap>) -> Result { + let cluster_count = groups.len(); + if cluster_count < 2 { + return Err(MembershipError::InsufficientClusterStructure); + } + let mut sample_size = 0_usize; + let mut total = 0.0; + for values in groups.values() { + sample_size += values.len(); + for &value in values { + total += value; + } + } + if sample_size <= cluster_count { + return Err(MembershipError::InsufficientClusterStructure); + } + let n = sample_size as f64; + let j = cluster_count as f64; + let grand_mean = total / n; + let mut sum_of_squares_between = 0.0; + let mut sum_of_squares_within = 0.0; + let mut sum_cluster_size_squared = 0.0; + for values in groups.values() { + let cluster_size = values.len() as f64; + sum_cluster_size_squared += cluster_size * cluster_size; + let mut cluster_total = 0.0; + for &value in values { + cluster_total += value; + } + let cluster_mean = cluster_total / cluster_size; + let between = cluster_mean - grand_mean; + sum_of_squares_between += cluster_size * between * between; + for &value in values { + let within = value - cluster_mean; + sum_of_squares_within += within * within; + } + } + if sum_of_squares_between + sum_of_squares_within == 0.0 { + return Err(MembershipError::InsufficientClusterStructure); + } + let mean_square_between = sum_of_squares_between / (j - 1.0); + let mean_square_within = sum_of_squares_within / (n - j); + let harmonic_cluster_size = (n - sum_cluster_size_squared / n) / (j - 1.0); + let cluster_variance = + ((mean_square_between - mean_square_within) / harmonic_cluster_size).max(0.0); + Ok(cluster_variance / (cluster_variance + mean_square_within)) +} + +#[cfg(test)] +mod tests { + use super::{MembershipDesign, NestedOutcome, anova_nested_icc}; + use crate::{GroupId, MemberId, MembershipError}; + use std::collections::BTreeMap; + + #[test] + fn design_gate_and_outcome_accessors_cover_local_branches() { + assert!(MembershipDesign::Nested.allows_nested_icc()); + assert!(!MembershipDesign::CrossClassified.allows_nested_icc()); + assert!(!MembershipDesign::MultipleMembership.allows_nested_icc()); + let member = MemberId::new(); + let outcome = NestedOutcome::new(member, 1.5).expect("finite"); + assert_eq!(outcome.member_id(), member); + assert!((outcome.value() - 1.5).abs() < 1e-12); + assert_eq!( + NestedOutcome::new(member, f64::NEG_INFINITY), + Err(MembershipError::InvalidOutcome) + ); + let mut one = BTreeMap::new(); + one.insert(GroupId::new(), vec![1.0, 2.0]); + assert_eq!( + anova_nested_icc(&one), + Err(MembershipError::InsufficientClusterStructure) + ); + } +} diff --git a/crates/membership_core/src/lib.rs b/crates/membership_core/src/lib.rs index ffe3c85c..56c56705 100644 --- a/crates/membership_core/src/lib.rs +++ b/crates/membership_core/src/lib.rs @@ -13,6 +13,7 @@ mod assignment; mod error; mod ess; +mod icc; mod identifier; mod network; mod role; @@ -39,3 +40,11 @@ pub use ess::design_effect; pub use ess::group_normalized_kish_ess; /// Kish effective sample size for membership weights. pub use ess::kish_effective_sample_size; +/// Membership design implied by active assignments. +pub use icc::MembershipDesign; +/// Finite outcome used by the nested ICC estimator. +pub use icc::NestedOutcome; +/// Classify nested versus cross-classified versus multiple-membership designs. +pub use icc::classify_membership_design; +/// CPU `f64` nested ICC that refuses non-nested membership designs. +pub use icc::nested_intraclass_correlation; diff --git a/crates/membership_core/tests/nested_icc_recovery_contract.rs b/crates/membership_core/tests/nested_icc_recovery_contract.rs new file mode 100644 index 00000000..d3cf0c6c --- /dev/null +++ b/crates/membership_core/tests/nested_icc_recovery_contract.rs @@ -0,0 +1,293 @@ +//! Nested ICC recovery and fail-closed design-refusal contracts. +//! +//! These tests exist before the public ICC API so the increment is RED first. + +use membership_core::{ + GroupId, MemberId, MembershipAssignment, MembershipDesign, MembershipError, MembershipNetwork, + MembershipRole, MembershipWeight, NestedOutcome, classify_membership_design, + nested_intraclass_correlation, +}; +use temporal_core::EventTime; + +fn event_time(value: &str) -> EventTime { + EventTime::parse_rfc3339(value).expect("event time must parse") +} + +fn insert_nested( + network: &mut MembershipNetwork, + member: MemberId, + group: GroupId, + start: EventTime, + end: EventTime, +) { + network + .insert( + MembershipAssignment::new( + member, + group, + MembershipRole::Author, + MembershipWeight::full().expect("full weight"), + start, + end, + ) + .expect("assignment"), + ) + .expect("insert"); +} + +#[test] +fn balanced_anova_recovers_known_icc_and_reports_computed_rmse() { + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-12-31T00:00:00Z"); + let as_of = event_time("2026-06-01T00:00:00Z"); + let groups = [ + GroupId::new(), + GroupId::new(), + GroupId::new(), + GroupId::new(), + ]; + // Four groups of two: means 2,3,4,5 with within deviation ±1. + 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 network = MembershipNetwork::new(); + let mut outcomes = Vec::new(); + for (group, values) in rows { + for value in values { + let member = MemberId::new(); + insert_nested(&mut network, member, group, start, end); + outcomes.push(NestedOutcome::new(member, value).expect("finite outcome")); + } + } + + assert_eq!( + classify_membership_design(&network, as_of).expect("design"), + MembershipDesign::Nested + ); + assert!(MembershipDesign::Nested.allows_nested_icc()); + assert!(!MembershipDesign::CrossClassified.allows_nested_icc()); + assert!(!MembershipDesign::MultipleMembership.allows_nested_icc()); + + let recovered = nested_intraclass_correlation(&network, as_of, &outcomes).expect("nested ICC"); + let true_icc = 0.25; + let rmse = (recovered - true_icc).abs(); + assert!( + rmse < 1e-12, + "balanced ANOVA must recover ICC=1/4; recovered={recovered}, rmse={rmse}" + ); +} + +#[test] +fn zero_within_variance_recovers_unit_icc_and_equal_groups_recover_zero() { + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-12-31T00:00:00Z"); + let as_of = event_time("2026-06-01T00:00:00Z"); + + let mut perfect = MembershipNetwork::new(); + let g_lo = GroupId::new(); + let g_hi = GroupId::new(); + let mut perfect_outcomes = Vec::new(); + for value in [0.0, 0.0] { + let member = MemberId::new(); + insert_nested(&mut perfect, member, g_lo, start, end); + perfect_outcomes.push(NestedOutcome::new(member, value).expect("lo")); + } + for value in [4.0, 4.0] { + let member = MemberId::new(); + insert_nested(&mut perfect, member, g_hi, start, end); + perfect_outcomes.push(NestedOutcome::new(member, value).expect("hi")); + } + let unit = nested_intraclass_correlation(&perfect, as_of, &perfect_outcomes).expect("icc=1"); + assert!((unit - 1.0).abs() < 1e-12); + + let mut flat = MembershipNetwork::new(); + let g_a = GroupId::new(); + let g_b = GroupId::new(); + let mut flat_outcomes = Vec::new(); + for value in [1.0, 3.0] { + let member = MemberId::new(); + insert_nested(&mut flat, member, g_a, start, end); + flat_outcomes.push(NestedOutcome::new(member, value).expect("a")); + } + for value in [0.0, 4.0] { + let member = MemberId::new(); + insert_nested(&mut flat, member, g_b, start, end); + flat_outcomes.push(NestedOutcome::new(member, value).expect("b")); + } + let zero = nested_intraclass_correlation(&flat, as_of, &flat_outcomes).expect("icc=0"); + assert!(zero.abs() < 1e-12); +} + +#[test] +fn cross_classified_and_multiple_membership_refuse_nested_icc() { + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-12-31T00:00:00Z"); + let as_of = event_time("2026-06-01T00:00:00Z"); + let member = MemberId::new(); + let author = GroupId::new(); + let project = GroupId::new(); + + let mut cross = MembershipNetwork::new(); + insert_nested(&mut cross, member, author, start, end); + cross + .insert( + MembershipAssignment::new( + member, + project, + MembershipRole::Project, + MembershipWeight::new(0.5).expect("partial"), + start, + end, + ) + .expect("project"), + ) + .expect("insert project"); + assert_eq!( + classify_membership_design(&cross, as_of).expect("cc"), + MembershipDesign::CrossClassified + ); + let outcome = NestedOutcome::new(member, 1.0).expect("outcome"); + assert_eq!( + nested_intraclass_correlation(&cross, as_of, &[outcome]), + Err(MembershipError::NestedIccInapplicable) + ); + + let mut multiple = MembershipNetwork::new(); + let dept_a = GroupId::new(); + let dept_b = GroupId::new(); + multiple + .insert( + MembershipAssignment::new( + member, + dept_a, + MembershipRole::Department, + MembershipWeight::new(0.6).expect("a"), + start, + end, + ) + .expect("dept a"), + ) + .expect("insert a"); + multiple + .insert( + MembershipAssignment::new( + member, + dept_b, + MembershipRole::Department, + MembershipWeight::new(0.4).expect("b"), + start, + end, + ) + .expect("dept b"), + ) + .expect("insert b"); + assert_eq!( + classify_membership_design(&multiple, as_of).expect("mm"), + MembershipDesign::MultipleMembership + ); + assert_eq!( + nested_intraclass_correlation(&multiple, as_of, &[outcome]), + Err(MembershipError::NestedIccInapplicable) + ); +} + +#[test] +fn nested_icc_fails_closed_on_degenerate_inputs() { + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-12-31T00:00:00Z"); + let as_of = event_time("2026-06-01T00:00:00Z"); + let before = event_time("2025-01-01T00:00:00Z"); + + assert_eq!( + NestedOutcome::new(MemberId::new(), f64::NAN), + Err(MembershipError::InvalidOutcome) + ); + assert_eq!( + NestedOutcome::new(MemberId::new(), f64::INFINITY), + Err(MembershipError::InvalidOutcome) + ); + + let empty = MembershipNetwork::new(); + assert_eq!( + classify_membership_design(&empty, as_of), + Err(MembershipError::InsufficientClusterStructure) + ); + assert_eq!( + nested_intraclass_correlation(&empty, as_of, &[]), + Err(MembershipError::InsufficientClusterStructure) + ); + + let member = MemberId::new(); + let group = GroupId::new(); + let mut inactive = MembershipNetwork::new(); + insert_nested(&mut inactive, member, group, start, end); + assert_eq!( + classify_membership_design(&inactive, before), + Err(MembershipError::InsufficientClusterStructure) + ); + let outcome = NestedOutcome::new(member, 1.0).expect("finite"); + assert_eq!( + nested_intraclass_correlation(&inactive, before, &[outcome]), + Err(MembershipError::UnknownOutcomeMember) + ); + assert_eq!( + nested_intraclass_correlation(&inactive, as_of, &[outcome, outcome]), + Err(MembershipError::DuplicateOutcomeMember) + ); + assert_eq!( + nested_intraclass_correlation( + &inactive, + as_of, + &[NestedOutcome::new(MemberId::new(), 1.0).expect("stranger")] + ), + Err(MembershipError::UnknownOutcomeMember) + ); + + let only = NestedOutcome::new(member, 2.0).expect("one"); + assert_eq!( + nested_intraclass_correlation(&inactive, as_of, &[only]), + Err(MembershipError::InsufficientClusterStructure) + ); + + let mut singletons = MembershipNetwork::new(); + let g1 = GroupId::new(); + let g2 = GroupId::new(); + let m1 = MemberId::new(); + let m2 = MemberId::new(); + insert_nested(&mut singletons, m1, g1, start, end); + insert_nested(&mut singletons, m2, g2, start, end); + assert_eq!( + nested_intraclass_correlation( + &singletons, + as_of, + &[ + NestedOutcome::new(m1, 1.0).expect("s1"), + NestedOutcome::new(m2, 2.0).expect("s2"), + ] + ), + Err(MembershipError::InsufficientClusterStructure) + ); + + let mut constants = MembershipNetwork::new(); + let c1 = GroupId::new(); + let c2 = GroupId::new(); + let mut constant_outcomes = Vec::new(); + for group in [c1, c2] { + for _ in 0..2 { + let member = MemberId::new(); + insert_nested(&mut constants, member, group, start, end); + constant_outcomes.push(NestedOutcome::new(member, 5.0).expect("const")); + } + } + assert_eq!( + nested_intraclass_correlation(&constants, as_of, &constant_outcomes), + Err(MembershipError::InsufficientClusterStructure) + ); + + let inspected = NestedOutcome::new(member, -2.5).expect("signed"); + assert_eq!(inspected.member_id(), member); + assert!((inspected.value() + 2.5).abs() < 1e-12); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d9743..2f1da662 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -14,7 +14,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 `temporal_core` path-consistency on protected main | implemented-main | | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | | event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; full intelligence stack remaining | partial | -| time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | +| time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network and Kish ESS on protected main; nested ICC + cross-classified/multiple-membership refusal (this PR); full multilevel/MMMC/ESEM estimators remaining | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (active PR); remaining physical ERD constraints | partial | diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index c5b1a154..59c3f54d 100644 --- a/docs/adr/0003-relational-event-multiple-membership.md +++ b/docs/adr/0003-relational-event-multiple-membership.md @@ -1,7 +1,7 @@ # ADR 0003 — Relational event ontology and time-varying multiple membership **Decision status:** Accepted -**Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; typed relation graph with forward-only transitions active-PR; multilevel estimators and persistence remain accepted-target +**Implementation maturity:** partial — membership network, event mention/instance separation, and Kish ESS implemented-main; nested ICC with cross-classified/multiple-membership refusal is this increment; full multilevel/MMMC estimators and remaining persistence remain accepted-target **Date:** 2026-08-05 **Supersedes:** None. ADR 0016 owns TDT/CHRONOS event-intelligence task semantics; this ADR remains authoritative for ontology, relation, role, and membership structure. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b31..e8e91676 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio |---|---|---|---|---| | [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. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Unmerged PR #8 is the canonical Task 3 replacement implementing typed clocks/intervals against the current protected-main lineage; conflicted PR #5 is superseded lineage. Later graph/split enforcement remains target work. | -| [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Weighted time-varying membership network/roles are active-PR (PR #12); full multilevel estimators, graph ontology, and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | +| [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Membership network/roles and Kish ESS are implemented-main; this increment adds nested ICC with fail-closed cross-classified/multiple-membership refusal. Full multilevel/MMMC estimators, graph ontology, and remaining persistence stay accepted-target. ADR 0016 owns event-intelligence tasks. | | [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | accepted-target | ADR 0012 owns the full topic-estimator/backend/global-topic contract. | | [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | accepted-target | Downstream psychometric authority; upstream topic/network model is clarified by ADR 0012. | | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | diff --git a/docs/research/nested-icc-cross-classified-refusal.md b/docs/research/nested-icc-cross-classified-refusal.md new file mode 100644 index 00000000..d38c6903 --- /dev/null +++ b/docs/research/nested-icc-cross-classified-refusal.md @@ -0,0 +1,32 @@ +# Nested ICC and cross-classified refusal + +## Claim boundary + +This increment adds a CPU `f64` nested intraclass correlation for one-way clustered membership and refuses to apply that estimator when the active design is cross-classified or multiple-membership. It is not a full MMMC, HLM, ESEM, or DSEM fit, not a causal identification claim, and not a persistence/migration change. + +## Authority + +Raudenbush, S. W., & Bryk, A. S. (2002). *Hierarchical linear models: Applications and data analysis methods* (2nd ed.). Sage. + +Snijders, T. A. B., & Bosker, R. J. (2012). *Multilevel analysis: An introduction to basic and advanced multilevel modeling* (2nd ed.). SAGE. + +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 + +Beretvas, S. N. (2011). Cross-classified and multiple-membership models. In J. J. Hox & J. K. Roberts (Eds.), *Handbook of advanced multilevel analysis* (pp. 313–334). Routledge. + +## Estimator + +For a nested design the unbalanced ANOVA estimator is + +`ICC = σ²_u / (σ²_u + σ²_e)`, + +with `σ²_e = MSW` and `σ²_u = max(0, (MSB − MSW) / n₀)`, where `n₀` is the Snijders–Bosker unbalanced cluster-size factor. Empty, singleton-only, or zero-variance samples fail closed. + +A member who is active in two roles is cross-classified. A member who is active in two groups of one role has multiple membership. Neither design may be collapsed into a nested ICC. + +## Verification + +- balanced four-by-two ANOVA recovers ICC `1/4` with computed RMSE versus that known truth; +- zero within-cluster residual recovers ICC `1`; equal cluster means recover ICC `0`; +- cross-classified and multiple-membership networks return `NestedIccInapplicable`; +- empty, inactive, duplicate, unknown, singleton, and constant samples fail closed. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b14468..d2721f5d 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -68,6 +68,16 @@ Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reaso TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. +Raudenbush, S. W., & Bryk, A. S. (2002). *Hierarchical linear models: Applications and data analysis methods* (2nd ed.). Sage. + +Snijders, T. A. B., & Bosker, R. J. (2012). *Multilevel analysis: An introduction to basic and advanced multilevel modeling* (2nd ed.). SAGE. + +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 + +Beretvas, S. N. (2011). Cross-classified and multiple-membership models. In J. J. Hox & J. K. Roberts (Eds.), *Handbook of advanced multilevel analysis* (pp. 313–334). Routledge. + +Nested ICC recovery uses the unbalanced ANOVA variance-component estimator and refuses to treat cross-classified or multiple-membership designs as one hierarchy (Raudenbush & Bryk, 2002; Snijders & Bosker, 2012; Browne et al., 2001; Beretvas, 2011). + ## Unicode, language tags, and multilingual structure Davis, M., Iancu, L., & Whistler, K. (Eds.). (2024). *Unicode Standard Annex #15: Unicode normalization forms*. Unicode Consortium. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0..5ecde2e0 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -16,7 +16,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Six-clock temporal | `temporal_core` | implemented-main | — | unit + wire | Task 3 / PR #8 | | Allen path-consistency | `temporal_core` | implemented-main | — | unit + budget tests | Task 4 / PR #9 | | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | -| Multiple membership | `membership_core` | partial | — | unit + ESS weights | Task 7 / PR #12 + #25 | +| Multiple membership | `membership_core` | partial | nested ICC + non-nested refusal | unit + ESS + nested ICC recovery | Task 7 / PR #12 + #25 + this increment | | Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | | Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#43 + restore integrity | | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 |