Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/repair-pr54-distinct-membership-groups.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
7 changes: 7 additions & 0 deletions crates/membership_core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -68,6 +71,10 @@ mod tests {
MembershipError::DuplicateMembershipAssignment,
"duplicate membership assignment",
),
(
MembershipError::AtomisticCollapseRefused,
"atomistic membership collapse refused",
),
] {
assert_eq!(error.to_string(), message);
}
Expand Down
4 changes: 4 additions & 0 deletions crates/membership_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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;
118 changes: 114 additions & 4 deletions crates/membership_core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<EstimationMembershipRow>, MembershipError> {
let rows: Vec<EstimationMembershipRow> = 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<Item = MembershipAssignment> + '_ {
self.assignments.iter().copied()
Expand All @@ -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()
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
);
}
}
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading
Loading