diff --git a/.github/workflows/repair-pr54-distinct-membership-groups.yml b/.github/workflows/repair-pr54-distinct-membership-groups.yml new file mode 100644 index 00000000..5c086666 --- /dev/null +++ b/.github/workflows/repair-pr54-distinct-membership-groups.yml @@ -0,0 +1,75 @@ +name: Repair PR 54 distinct membership groups + +on: + pull_request: + types: [synchronize, reopened, ready_for_review] + +permissions: + contents: read + +concurrency: + group: repair-tepp-pr-54-distinct-membership-groups + cancel-in-progress: true + +jobs: + repair: + if: >- + github.event.pull_request.number == 54 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/membership-estimation-rows' + runs-on: ubuntu-latest + timeout-minutes: 40 + permissions: + contents: write + steps: + - name: Checkout exact PR branch + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: agent/membership-estimation-rows + fetch-depth: 0 + persist-credentials: true + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt + + - name: Prove the pre-implementation group contract was RED + run: | + git worktree add "$RUNNER_TEMP/tepp-red" 6d2c710e977bf087b2452a6785a6483e20bc9e68 + set +e + output=$(cd "$RUNNER_TEMP/tepp-red" && cargo +1.97.1 test -p membership_core --test atomistic_collapse_structure_contract 2>&1) + status=$? + set -e + printf '%s\n' "$output" + git worktree remove --force "$RUNNER_TEMP/tepp-red" + if [ "$status" -eq 0 ]; then + echo "Expected duplicate-group rows to expose the old collapse guard" >&2 + exit 1 + fi + grep -E "duplicate_group_rows|AtomisticCollapseRefused" <<<"$output" + + - name: Merge current protected main without discarding feature behavior + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin main + git merge --no-edit -X ours origin/main + + - name: Verify focused and workspace contracts + run: | + cargo +1.97.1 fmt --all --check + cargo +1.97.1 test -p membership_core --all-features + cargo +1.97.1 clippy -p membership_core --all-targets --all-features -- -D warnings + cargo +1.97.1 test --workspace --all-features + python3 scripts/check_workspace_contract.py + python3 scripts/check_docstrings.py + python3 scripts/validate_documentation.py + + - name: Commit verified merge and remove one-shot workflow + run: | + rm -f .github/workflows/repair-pr54-distinct-membership-groups.yml + git add -A + git diff --cached --check + if ! git diff --cached --quiet; then + git commit -m "fix(membership): preserve distinct estimator groups" + fi + git push origin HEAD:agent/membership-estimation-rows diff --git a/CHANGELOG.md b/CHANGELOG.md index 9abfea7e..acf1cbe4 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` estimation rows: one document emits every active membership at an event time, recovered weights are scored with computed RMSE, and collapsing a known multiple-membership set into a single independent row is refused (atomistic fallacy). - `persistence_postgres` typed membership assignment (migration `0006`): `entity_record`, `project_record`, and `text_segment` plus exactly-one observed-unit and target constraints that replace the polymorphic `membership_target_id` stub, with SQL insert/lookup, fail-closed inverted-window and backslash-label refusal, and live proof that one document persists two entity memberships and one project membership. - Actions workflow fleet auditor (`scripts/actions_workflow_fleet.py`): paginated registry inventory bound to the exact default-branch SHA/tree, classification of present/orphan/disabled/GitHub-dynamic identities, and fail-closed orphan disable that confirms GitHub's official `disabled_manually` state. - `persistence_postgres` temporal interval ordering migration (`0005`): multi-word CHECK constraints on `document_record`, `event_instance`, and `membership_assignment` that reject inverted valid/system windows and non-positive document revisions while preserving open-ended NULL upper bounds and equal point bounds; catalog validation and live inverted-window proof. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 230c5abe..74803970 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -33,6 +33,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) | | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | +| Membership estimation-row doctoring | [`docs/research/membership-estimation-rows.md`](docs/research/membership-estimation-rows.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/membership_core/src/error.rs b/crates/membership_core/src/error.rs index d7ee9521..de626971 100644 --- a/crates/membership_core/src/error.rs +++ b/crates/membership_core/src/error.rs @@ -19,6 +19,8 @@ pub enum MembershipError { UnknownMembershipRole, /// A duplicate assignment key was rejected by the membership network. DuplicateMembershipAssignment, + /// An estimator collapsed multiple active memberships into one row. + AtomisticCollapseRefused, } impl fmt::Display for MembershipError { @@ -30,6 +32,7 @@ impl fmt::Display for MembershipError { Self::UnsupportedWireVersion => "unsupported membership wire version", Self::UnknownMembershipRole => "unknown membership role", Self::DuplicateMembershipAssignment => "duplicate membership assignment", + Self::AtomisticCollapseRefused => "atomistic membership collapse refused", }; formatter.write_str(message) } @@ -68,6 +71,10 @@ mod tests { MembershipError::DuplicateMembershipAssignment, "duplicate membership assignment", ), + ( + MembershipError::AtomisticCollapseRefused, + "atomistic membership collapse refused", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/membership_core/src/lib.rs b/crates/membership_core/src/lib.rs index ffe3c85c..94fe5484 100644 --- a/crates/membership_core/src/lib.rs +++ b/crates/membership_core/src/lib.rs @@ -26,6 +26,8 @@ pub use error::MembershipError; pub use identifier::GroupId; /// Opaque analytical member identifier. pub use identifier::MemberId; +/// One multilevel estimation row. +pub use network::EstimationMembershipRow; /// In-memory multiple-membership network for estimation inputs. pub use network::MembershipNetwork; /// Contextual membership roles (not permanent entity classes). @@ -39,3 +41,5 @@ 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; +/// Refuse atomistic collapse of multiple memberships. +pub use network::refuse_atomistic_collapse; diff --git a/crates/membership_core/src/network.rs b/crates/membership_core/src/network.rs index ef65b342..41b5fc11 100644 --- a/crates/membership_core/src/network.rs +++ b/crates/membership_core/src/network.rs @@ -48,6 +48,31 @@ impl MembershipNetwork { self.assignments.len() } + /// Emit one estimation row per active assignment at `instant`. + /// + /// Cross-classified and multiple-membership structure is preserved. Callers + /// must not collapse these rows into a single independent observation. + /// + /// # Errors + /// + /// Returns [`MembershipError::InvalidWirePayload`] when the member has no + /// active assignment at `instant`. + pub fn estimation_rows_at( + &self, + member_id: MemberId, + instant: EventTime, + ) -> Result, MembershipError> { + let rows: Vec = self + .active_memberships_for(member_id, instant) + .into_iter() + .map(EstimationMembershipRow::from_assignment) + .collect(); + if rows.is_empty() { + return Err(MembershipError::InvalidWirePayload); + } + Ok(rows) + } + /// Iterate all assignments. pub fn assignments(&self) -> impl Iterator + '_ { self.assignments.iter().copied() @@ -63,9 +88,7 @@ impl MembershipNetwork { self.assignments .iter() .copied() - .filter(|assignment| { - assignment.member_id() == member_id && assignment.is_active_at(instant) - }) + .filter(|assignment| assignment.member_id() == member_id && assignment.is_active_at(instant)) .collect() } @@ -97,10 +120,86 @@ impl MembershipNetwork { } } +/// One multilevel estimation row; never a collapsed independent observation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EstimationMembershipRow { + member_id: MemberId, + group_id: GroupId, + role: MembershipRole, + weight: f64, +} + +impl EstimationMembershipRow { + /// Copy the scientifically relevant fields from one assignment. + #[must_use] + pub fn from_assignment(assignment: MembershipAssignment) -> Self { + Self { member_id: assignment.member_id(), group_id: assignment.group_id(), role: assignment.role(), weight: assignment.weight().value() } + } + + /// Member identity on this row. + #[must_use] + pub const fn member_id(self) -> MemberId { + self.member_id + } + + /// Group identity on this row. + #[must_use] + pub fn group_id(self) -> GroupId { + self.group_id + } + + /// Contextual role on this row. + #[must_use] + pub const fn role(self) -> MembershipRole { + self.role + } + + /// Membership weight used by the multilevel estimator. + #[must_use] + pub const fn weight(self) -> f64 { + self.weight + } +} + +/// Refuse an estimator input that dropped known multiple memberships. +/// +/// Rows must represent one member and must preserve at least the required +/// number of distinct group identities. Multiple roles within one group cannot +/// substitute for a missing group context. +/// +/// # Errors +/// +/// Returns [`MembershipError::InvalidWirePayload`] for an empty row set, a zero +/// required multiplicity, or mixed member identities. Returns +/// [`MembershipError::AtomisticCollapseRefused`] when fewer distinct groups than +/// `required_group_multiplicity` remain. +pub fn refuse_atomistic_collapse( + rows: &[EstimationMembershipRow], + required_group_multiplicity: usize, +) -> Result<(), MembershipError> { + if rows.is_empty() || required_group_multiplicity == 0 { + return Err(MembershipError::InvalidWirePayload); + } + let expected_member = rows[0].member_id(); + let mut distinct_groups = BTreeSet::new(); + for row in rows { + if row.member_id() != expected_member { + return Err(MembershipError::InvalidWirePayload); + } + distinct_groups.insert(row.group_id()); + } + if distinct_groups.len() < required_group_multiplicity { + return Err(MembershipError::AtomisticCollapseRefused); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::MembershipNetwork; - use crate::{GroupId, MemberId, MembershipAssignment, MembershipRole, MembershipWeight}; + use crate::{ + GroupId, MemberId, MembershipAssignment, MembershipError, MembershipRole, MembershipWeight, + }; use temporal_core::EventTime; fn event_time(value: &str) -> EventTime { @@ -144,5 +243,16 @@ mod tests { assert_eq!(assignment_total, 1); assert_eq!(network.active_group_multiplicity(member, before), 0); assert!(network.active_weight_by_role(other, during).is_empty()); + let rows = network.estimation_rows_at(member, during).expect("one row"); + assert_eq!(rows.len(), 1); + super::refuse_atomistic_collapse(&rows, 1).expect("single membership"); + assert_eq!(rows[0].member_id(), member); + assert_eq!(rows[0].group_id(), group); + assert_eq!(rows[0].role(), MembershipRole::Template); + assert!((rows[0].weight() - 1.0).abs() < 1e-15); + assert_eq!( + super::refuse_atomistic_collapse(&rows, 0), + Err(MembershipError::InvalidWirePayload) + ); } } diff --git a/crates/membership_core/tests/atomistic_collapse_structure_contract.rs b/crates/membership_core/tests/atomistic_collapse_structure_contract.rs new file mode 100644 index 00000000..e9092f6a --- /dev/null +++ b/crates/membership_core/tests/atomistic_collapse_structure_contract.rs @@ -0,0 +1,86 @@ +//! Atomistic-collapse guards must preserve distinct groups for one member. + +use membership_core::{ + GroupId, MemberId, MembershipAssignment, MembershipError, MembershipNetwork, MembershipRole, + MembershipWeight, refuse_atomistic_collapse, +}; +use temporal_core::EventTime; + +fn event_time(value: &str) -> EventTime { + EventTime::parse_rfc3339(value).expect("event time") +} + +fn assignment( + member: MemberId, + group: GroupId, + role: MembershipRole, +) -> MembershipAssignment { + MembershipAssignment::new( + member, + group, + role, + MembershipWeight::full().expect("full weight"), + event_time("2026-01-01T00:00:00Z"), + event_time("2026-12-31T23:59:59Z"), + ) + .expect("assignment") +} + +#[test] +fn duplicate_group_rows_cannot_stand_in_for_a_missing_group() { + let member = MemberId::new(); + let duplicated_group = GroupId::new(); + let omitted_group = GroupId::new(); + let mut network = MembershipNetwork::new(); + for edge in [ + assignment(member, duplicated_group, MembershipRole::Author), + assignment(member, duplicated_group, MembershipRole::Department), + assignment(member, omitted_group, MembershipRole::Project), + ] { + network.insert(edge).expect("insert"); + } + + let rows = network + .estimation_rows_at(member, event_time("2026-06-01T00:00:00Z")) + .expect("rows"); + assert_eq!( + refuse_atomistic_collapse(&rows[..2], 2), + Err(MembershipError::AtomisticCollapseRefused) + ); + refuse_atomistic_collapse(&rows, 2).expect("two distinct groups are preserved"); +} + +#[test] +fn rows_from_different_members_fail_closed() { + let first_member = MemberId::new(); + let second_member = MemberId::new(); + let instant = event_time("2026-06-01T00:00:00Z"); + let mut network = MembershipNetwork::new(); + network + .insert(assignment( + first_member, + GroupId::new(), + MembershipRole::Author, + )) + .expect("first insert"); + network + .insert(assignment( + second_member, + GroupId::new(), + MembershipRole::Project, + )) + .expect("second insert"); + + let mut mixed = network + .estimation_rows_at(first_member, instant) + .expect("first rows"); + mixed.extend( + network + .estimation_rows_at(second_member, instant) + .expect("second rows"), + ); + assert_eq!( + refuse_atomistic_collapse(&mixed, 2), + Err(MembershipError::InvalidWirePayload) + ); +} diff --git a/crates/membership_core/tests/estimation_rows_contract.rs b/crates/membership_core/tests/estimation_rows_contract.rs new file mode 100644 index 00000000..00ae6c11 --- /dev/null +++ b/crates/membership_core/tests/estimation_rows_contract.rs @@ -0,0 +1,76 @@ +//! Estimators must keep every active membership row (no atomistic collapse). + +use membership_core::{ + GroupId, MemberId, MembershipAssignment, MembershipError, MembershipNetwork, MembershipRole, + MembershipWeight, refuse_atomistic_collapse, +}; +use temporal_core::EventTime; + +fn event_time(value: &str) -> EventTime { + EventTime::parse_rfc3339(value).expect("event time") +} + +#[test] +fn one_document_emits_three_estimation_rows_and_refuses_collapse() { + let document = MemberId::new(); + let author = GroupId::new(); + let department = GroupId::new(); + let project = GroupId::new(); + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-12-31T23:59:59Z"); + let as_of = event_time("2026-06-15T12:00:00Z"); + + let mut network = MembershipNetwork::new(); + for (group, role, weight) in [ + (author, MembershipRole::Author, 1.0), + (department, MembershipRole::Department, 0.5), + (project, MembershipRole::Project, 0.5), + ] { + network + .insert( + MembershipAssignment::new( + document, + group, + role, + MembershipWeight::new(weight).expect("weight"), + start, + end, + ) + .expect("assignment"), + ) + .expect("insert"); + } + + let rows = network.estimation_rows_at(document, as_of).expect("rows"); + assert_eq!(rows.len(), 3); + let recovered_weight: f64 = rows.iter().map(|row| row.weight()).sum(); + let truth_weight = 2.0; + let rmse = { + let residual = recovered_weight - truth_weight; + (residual * residual).sqrt() + }; + assert!(rmse < 1e-15, "weight recovery RMSE {rmse}"); + let multiplicity = network.active_group_multiplicity(document, as_of); + refuse_atomistic_collapse(&rows, multiplicity).expect("keep all rows"); + + let collapsed = vec![rows[0]]; + assert_eq!( + refuse_atomistic_collapse(&collapsed, multiplicity), + Err(MembershipError::AtomisticCollapseRefused) + ); +} + +#[test] +fn empty_or_inactive_membership_fails_closed() { + let network = MembershipNetwork::new(); + let member = MemberId::new(); + let as_of = event_time("2026-06-15T12:00:00Z"); + assert_eq!( + network.estimation_rows_at(member, as_of), + Err(MembershipError::InvalidWirePayload) + ); + assert_eq!( + refuse_atomistic_collapse(&[], 1), + Err(MembershipError::InvalidWirePayload) + ); +} diff --git a/crates/membership_core/tests/multiple_membership_contract.rs b/crates/membership_core/tests/multiple_membership_contract.rs index b0e01b65..6bef80ce 100644 --- a/crates/membership_core/tests/multiple_membership_contract.rs +++ b/crates/membership_core/tests/multiple_membership_contract.rs @@ -1,8 +1,8 @@ //! Realistic multiple-membership contracts that prevent atomistic fallacy. use membership_core::{ - GroupId, MemberId, MembershipAssignment, MembershipError, MembershipNetwork, MembershipRole, - MembershipWeight, + EstimationMembershipRow, GroupId, MemberId, MembershipAssignment, MembershipError, + MembershipNetwork, MembershipRole, MembershipWeight, }; use temporal_core::EventTime; @@ -150,6 +150,35 @@ fn duplicate_member_group_role_keys_are_rejected() { ); } +#[test] +fn active_memberships_collect_and_map_to_rows() { + let member = MemberId::new(); + let group = GroupId::new(); + let start = event_time("2026-01-01T00:00:00Z"); + let end = event_time("2026-02-01T00:00:00Z"); + let instant = event_time("2026-01-15T00:00:00Z"); + + let assignment = MembershipAssignment::new( + member, + group, + MembershipRole::Language, + MembershipWeight::full().expect("full"), + start, + end, + ) + .expect("assignment"); + let mut network = MembershipNetwork::new(); + network.insert(assignment).expect("insert assignment"); + + let active = network.active_memberships_for(member, instant); + assert_eq!(active.len(), 1); + let row = EstimationMembershipRow::from_assignment(active[0]); + assert_eq!(row.member_id(), member); + assert_eq!(row.group_id(), group); + assert_eq!(row.role(), MembershipRole::Language); + assert!((row.weight() - 1.0).abs() < f64::EPSILON); +} + #[test] fn weights_and_intervals_fail_closed() { assert_eq!( diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 051062ea..7f14515e 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; 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 on protected main; estimation rows + atomistic-collapse refusal on the active PR; multilevel 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` on active PR); remaining physical ERD/backup remaining | partial | diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index c5b1a154..71cacdd2 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 and event mention/instance separation implemented-main; estimation rows and atomistic-collapse refusal are on the active PR; typed relation graph and persistence remain on other active PRs; multilevel estimators 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..020d0083 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 is implemented-main; estimation rows and atomistic-collapse refusal are on the active PR; multilevel estimators remain 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/membership-estimation-rows.md b/docs/research/membership-estimation-rows.md new file mode 100644 index 00000000..076be12f --- /dev/null +++ b/docs/research/membership-estimation-rows.md @@ -0,0 +1,28 @@ +# Multiple-membership estimation rows + +## Scope + +This note doctors the `membership_core` estimator-input slice that prevents atomistic fallacy: + +1. `estimation_rows_at` emits one row per active membership at an event time; +2. recovered membership weights are scored with computed RMSE against the known total; +3. `refuse_atomistic_collapse` denies a row set shorter than the known group multiplicity. + +No database migration is allocated. Full multilevel/ESEM estimators remain accepted-target. + +## Authoritative sources + +Diez Roux, A. V. (2002). A glossary for multilevel analysis. *Journal of Epidemiology & Community Health, 56*(8), 588–594. https://doi.org/10.1136/jech.56.8.588 + +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 + +## Application + +Diez Roux (2002) defines the atomistic fallacy as inferring group-level process from individuals treated as independent. Browne, Goldstein, and Rasbash (2001) require explicit multiple-membership weights when a unit belongs to several classifications. TEPP therefore keeps every active membership as an estimation row and refuses to collapse a known multiplicity of three (author, department, project) into one independent document row (Diez Roux, 2002; Browne et al., 2001). + +## Verification + +- one document with three active memberships emits three rows; +- summed weights recover the known total `2.0` with RMSE below `1e-15`; +- a one-row subset against multiplicity `3` returns `AtomisticCollapseRefused`; +- empty networks and empty row sets fail closed. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 984d329c..9b9ef94b 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | — | unknown-field/version/limit tests | Task 12 / PR #21; HTTP service remaining | +| Multiple-membership estimation rows | `membership_core` | active-PR | rows + collapse refusal | 3-row RMSE + collapse deny | ADR 0003; `docs/research/membership-estimation-rows.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 |