From c0fbaabd8c95e69407c3b9e50f8d1846bd949598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:14:08 +0000 Subject: [PATCH 1/3] feat(analysis): bind membership-target refusals to an analysis-run profile GAP-004 leftover / ADR 0069. Bind existing MembershipTargetKind and refuse_collapsed_target to cutoff-safe membership_target_v1. Language, episode, template, department, and opportunity-pool targets are not entities; identity_recovery_rate stays library-side. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 2 + crates/analysis_engine/src/lib.rs | 15 + .../src/membership_target_artifact.rs | 468 ++++++++++++++++++ .../membership_target_execution_contract.rs | 228 +++++++++ docs/TRACEABILITY.md | 1 + .../0069-membership-target-analysis-run.md | 97 ++++ docs/adr/README.md | 2 + .../membership-target-analysis-run.md | 16 + 11 files changed, 833 insertions(+) create mode 100644 crates/analysis_engine/src/membership_target_artifact.rs create mode 100644 crates/analysis_engine/tests/membership_target_execution_contract.rs create mode 100644 docs/adr/0069-membership-target-analysis-run.md create mode 100644 docs/doctoring/membership-target-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..7ad4f4daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- **Membership-target analysis-run profile**: `analysis_engine` binds existing `membership_target::MembershipTargetKind` and `refuse_collapsed_target` to cutoff-safe `membership_target_v1` (`tepp.membership_target.v1`) with inference status `language_episode_template_department_opportunity_pool_are_not_entities`. `identity_recovery_rate` stays library-side. Not location-membership, not membership-posterior ICC, not copied-text, not copy-identity, not GPU, not MCMC, and not topic birth/split/merge. + - `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..e0991de6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "membership_target", "relation_graph", "serde", "serde_json", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..f4f940c8c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -71,6 +71,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Analysis engine v1 doctoring | [`docs/doctoring/analysis-engine-v1.md`](docs/doctoring/analysis-engine-v1.md) | | Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | +| Membership-target analysis-run doctoring | [`docs/doctoring/membership-target-analysis-run.md`](docs/doctoring/membership-target-analysis-run.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..2afb14c58 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +membership_target = { path = "../membership_target", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -24,6 +25,7 @@ topic_measurement = { path = "../topic_measurement", version = "0.2.0" } uuid.workspace = true [dev-dependencies] +membership_target = { path = "../membership_target", version = "0.2.0" } 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" } diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..e981fc36f 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -12,6 +12,7 @@ mod case_deletion_refit; mod lineage_criterion; +mod membership_target_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +47,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Membership-target artifact and execution contracts from this engine. +pub use membership_target_artifact::{ + MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, MEMBERSHIP_TARGET_OUTPUT_PROFILE, + MembershipTargetArtifact, MembershipTargetDocument, MembershipTargetExecution, + execute_membership_target_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 +256,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A membership-target artifact violated its bounded schema or count invariants. + InvalidMembershipTargetArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +272,7 @@ 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::InvalidMembershipTargetArtifact => "invalid membership-target artifact", }; formatter.write_str(message) } @@ -681,6 +692,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidMembershipTargetArtifact, + "invalid membership-target artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs new file mode 100644 index 000000000..2e4224c35 --- /dev/null +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -0,0 +1,468 @@ +//! Digest-bound membership-target refusals as an analysis-run profile. + +use membership_target::{refuse_collapsed_target, MembershipTargetError, MembershipTargetKind}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{format_digest, require_receipt_identity, valid_identifier, AnalysisEngineError}; + +/// Versioned schema for a completed membership-target artifact. +pub const MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION: &str = "tepp.membership_target.v1"; +/// Model contract required by the membership-target execution path. +pub const MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION: &str = "membership_target_v1"; +/// Analysis-run output profile required for a membership-target artifact. +pub const MEMBERSHIP_TARGET_OUTPUT_PROFILE: &str = "membership_target_v1"; +/// Maximum canonical artifact JSON size. +pub const MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const MEMBERSHIP_TARGET_INFERENCE_STATUS: &str = + "language_episode_template_department_opportunity_pool_are_not_entities"; + +/// One cutoff-admitted membership treatment with a closed target kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MembershipTargetDocument { + document_id: String, + kind: MembershipTargetKind, +} + +impl MembershipTargetDocument { + /// Construct a bounded membership-target document. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document + /// identity is empty or oversized. + pub fn new( + document_id: impl Into, + kind: MembershipTargetKind, + ) -> Result { + let document_id = document_id.into(); + if !valid_identifier(&document_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { document_id, kind }) + } + + /// Return the opaque document identity. + #[must_use] + pub fn document_id(&self) -> &str { + &self.document_id + } + + /// Return the closed membership-target kind. + #[must_use] + pub const fn kind(&self) -> MembershipTargetKind { + self.kind + } +} + +/// Completed, bounded membership-target census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct MembershipTargetArtifact { + /// 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 to admit documents. + pub knowledge_cutoff: String, + /// Number of documents admitted at the cutoff. + pub document_count: u64, + /// Language-community treatments admitted at the cutoff. + pub language_count: u64, + /// Episode treatments admitted at the cutoff. + pub episode_count: u64, + /// Template-family treatments admitted at the cutoff. + pub template_count: u64, + /// Department treatments admitted at the cutoff. + pub department_count: u64, + /// Opportunity-pool treatments admitted at the cutoff. + pub opportunity_pool_count: u64, + /// Entity treatments admitted at the cutoff. + pub entity_count: u64, + /// Project treatments admitted at the cutoff. + pub project_count: u64, + /// Typed non-entity/project kinds refused as entity. + pub refused_as_entity_count: u64, + /// Typed non-entity/project kinds refused as project. + pub refused_as_project_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl MembershipTargetArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidMembershipTargetArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidMembershipTargetArtifact)?; + 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_TARGET_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> { + let typed_sum = self + .language_count + .checked_add(self.episode_count) + .and_then(|value| value.checked_add(self.template_count)) + .and_then(|value| value.checked_add(self.department_count)) + .and_then(|value| value.checked_add(self.opportunity_pool_count)); + let persistence_sum = self.entity_count.checked_add(self.project_count); + let kind_sum = typed_sum + .and_then(|typed| persistence_sum.and_then(|persisted| typed.checked_add(persisted))); + if self.schema_version != MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.document_count < 2 + || typed_sum == Some(0) + || persistence_sum == Some(0) + || kind_sum != Some(self.document_count) + || self.refused_as_entity_count != typed_sum.unwrap_or(0) + || self.refused_as_project_count != typed_sum.unwrap_or(0) + || self.inference_status != MEMBERSHIP_TARGET_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidMembershipTargetArtifact); + } + Ok(()) + } +} + +/// One completed membership-target artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct MembershipTargetExecution { + /// Digest-bound completed membership-target census. + pub artifact: MembershipTargetArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe membership-target refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_collapsed_target`] already on protected main. +/// Language, episode, template, department, and opportunity-pool kinds stay +/// distinct from entity and project. It does not emit `identity_recovery_rate`, +/// a `scientific_acceptance` inspect metric, GPU kernels, MCMC, or topic +/// birth/split/merge events. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, empty or +/// single-class corpus, duplicate document identity, or invalid artifact error. +#[allow(clippy::too_many_lines)] +pub fn execute_membership_target_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + documents: &[MembershipTargetDocument], + 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_TARGET_MODEL_CONTRACT_VERSION + || request.output_profile != MEMBERSHIP_TARGET_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut language_count = 0_u64; + let mut episode_count = 0_u64; + let mut template_count = 0_u64; + let mut department_count = 0_u64; + let mut opportunity_pool_count = 0_u64; + let mut entity_count = 0_u64; + let mut project_count = 0_u64; + let mut refused_as_entity_count = 0_u64; + let mut refused_as_project_count = 0_u64; + for document in documents { + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + MembershipTargetKind::Language + | MembershipTargetKind::Episode + | MembershipTargetKind::Template + | MembershipTargetKind::Department + | MembershipTargetKind::OpportunityPool => { + match refuse_collapsed_target(document.kind(), MembershipTargetKind::Entity) { + Err(MembershipTargetError::TargetKindCollapsed) => { + refused_as_entity_count = refused_as_entity_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match refuse_collapsed_target(document.kind(), MembershipTargetKind::Project) { + Err(MembershipTargetError::TargetKindCollapsed) => { + refused_as_project_count = refused_as_project_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match document.kind() { + MembershipTargetKind::Language => { + language_count = increment(language_count)?; + } + MembershipTargetKind::Episode => { + episode_count = increment(episode_count)?; + } + MembershipTargetKind::Template => { + template_count = increment(template_count)?; + } + MembershipTargetKind::Department => { + department_count = increment(department_count)?; + } + MembershipTargetKind::OpportunityPool => { + opportunity_pool_count = increment(opportunity_pool_count)?; + } + MembershipTargetKind::Entity | MembershipTargetKind::Project => { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + } + MembershipTargetKind::Entity => { + refuse_collapsed_target(document.kind(), MembershipTargetKind::Entity) + .map_err(map_membership_target_error)?; + entity_count = increment(entity_count)?; + } + MembershipTargetKind::Project => { + refuse_collapsed_target(document.kind(), MembershipTargetKind::Project) + .map_err(map_membership_target_error)?; + project_count = increment(project_count)?; + } + } + } + + let document_count = + u64::try_from(documents.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let typed_sum = language_count + .checked_add(episode_count) + .and_then(|value| value.checked_add(template_count)) + .and_then(|value| value.checked_add(department_count)) + .and_then(|value| value.checked_add(opportunity_pool_count)) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let persistence_sum = entity_count + .checked_add(project_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + if document_count < 2 || typed_sum == 0 || persistence_sum == 0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = MembershipTargetArtifact { + schema_version: MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + document_count, + language_count, + episode_count, + template_count, + department_count, + opportunity_pool_count, + entity_count, + project_count, + refused_as_entity_count, + refused_as_project_count, + inference_status: MEMBERSHIP_TARGET_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "membership_target", + document_count, + 4, + MEMBERSHIP_TARGET_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("membership_target_artifact_{}", &digest[..16]), + digest, + MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(MembershipTargetExecution { + artifact, + terminal_result, + }) +} + +fn increment(count: u64) -> Result { + count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + +fn map_membership_target_error(error: MembershipTargetError) -> AnalysisEngineError { + match error { + MembershipTargetError::TargetKindCollapsed + | MembershipTargetError::InvalidTargetPayload + | _ => AnalysisEngineError::InvalidEvidence, + } +} + +#[cfg(test)] +mod tests { + use super::{ + MembershipTargetArtifact, MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, + MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MEMBERSHIP_TARGET_INFERENCE_STATUS, + }; + use crate::AnalysisEngineError; + + fn artifact() -> MembershipTargetArtifact { + MembershipTargetArtifact { + schema_version: MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: 7, + language_count: 1, + episode_count: 1, + template_count: 1, + department_count: 1, + opportunity_pool_count: 1, + entity_count: 1, + project_count: 1, + refused_as_entity_count: 5, + refused_as_project_count: 5, + inference_status: MEMBERSHIP_TARGET_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &MembershipTargetArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidMembershipTargetArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + MembershipTargetArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + MembershipTargetArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidMembershipTargetArtifact) + ); + assert_eq!( + MembershipTargetArtifact::from_json( + &"x".repeat(MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT + 1) + ), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_metadata_tampering_fails_closed() { + let artifact = 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.document_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.language_count = 0; + value.episode_count = 0; + value.template_count = 0; + value.department_count = 0; + value.opportunity_pool_count = 0; + value.refused_as_entity_count = 0; + value.refused_as_project_count = 0; + value.document_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.entity_count = 0; + value.project_count = 0; + value.document_count = 5; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_entity_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs new file mode 100644 index 000000000..6d67140d5 --- /dev/null +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -0,0 +1,228 @@ +//! End-to-end contract for cutoff-safe membership-target refusals. + +use analysis_engine::{ + execute_membership_target_run, AnalysisEngineError, MembershipTargetDocument, + MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, + MEMBERSHIP_TARGET_OUTPUT_PROFILE, +}; +use membership_target::MembershipTargetKind; +use temporal_core::KnowledgeCutoff; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "membership-target-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-membership-target".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION.into(), + output_profile: MEMBERSHIP_TARGET_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-membership-target", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn mixed_documents() -> Vec { + vec![ + MembershipTargetDocument::new("lang-a", MembershipTargetKind::Language).expect("language"), + MembershipTargetDocument::new("ep-b", MembershipTargetKind::Episode).expect("episode"), + MembershipTargetDocument::new("tmpl-c", MembershipTargetKind::Template).expect("template"), + MembershipTargetDocument::new("dept-d", MembershipTargetKind::Department) + .expect("department"), + MembershipTargetDocument::new("pool-e", MembershipTargetKind::OpportunityPool) + .expect("opportunity"), + MembershipTargetDocument::new("ent-f", MembershipTargetKind::Entity).expect("entity"), + MembershipTargetDocument::new("proj-g", MembershipTargetKind::Project).expect("project"), + ] +} + +fn execute( + request: &AnalysisRunRequest, + documents: &[MembershipTargetDocument], +) -> Result { + execute_membership_target_run( + request, + &accepted(request), + "snapshot-membership-target", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_target_kinds_emit_digest_bound_refusals_without_recovery_metric() { + let request = request(); + let execution = execute(&request, &mixed_documents()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 7); + assert_eq!(execution.artifact.language_count, 1); + assert_eq!(execution.artifact.episode_count, 1); + assert_eq!(execution.artifact.template_count, 1); + assert_eq!(execution.artifact.department_count, 1); + assert_eq!(execution.artifact.opportunity_pool_count, 1); + assert_eq!(execution.artifact.entity_count, 1); + assert_eq!(execution.artifact.project_count, 1); + assert_eq!(execution.artifact.refused_as_entity_count, 5); + assert_eq!(execution.artifact.refused_as_project_count, 5); + assert_eq!( + execution.artifact.inference_status, + "language_episode_template_department_opportunity_pool_are_not_entities" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + 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_TARGET_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn empty_single_class_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let language_only = vec![ + MembershipTargetDocument::new("lang-a", MembershipTargetKind::Language).expect("language"), + MembershipTargetDocument::new("lang-b", MembershipTargetKind::Language).expect("language"), + ]; + assert_eq!( + execute(&request, &language_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let entity_only = vec![ + MembershipTargetDocument::new("ent-a", MembershipTargetKind::Entity).expect("entity"), + MembershipTargetDocument::new("ent-b", MembershipTargetKind::Entity).expect("entity"), + ]; + assert_eq!( + execute(&request, &entity_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let project_only = vec![ + MembershipTargetDocument::new("proj-a", MembershipTargetKind::Project).expect("project"), + MembershipTargetDocument::new("proj-b", MembershipTargetKind::Project).expect("project"), + ]; + assert_eq!( + execute(&request, &project_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let typed_only = vec![ + MembershipTargetDocument::new("lang-a", MembershipTargetKind::Language).expect("language"), + MembershipTargetDocument::new("ep-b", MembershipTargetKind::Episode).expect("episode"), + ]; + assert_eq!( + execute(&request, &typed_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let persistence_only = vec![ + MembershipTargetDocument::new("ent-a", MembershipTargetKind::Entity).expect("entity"), + MembershipTargetDocument::new("proj-b", MembershipTargetKind::Project).expect("project"), + ]; + assert_eq!( + execute(&request, &persistence_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + MembershipTargetDocument::new("same", MembershipTargetKind::Language).expect("language"), + MembershipTargetDocument::new("same", MembershipTargetKind::Entity).expect("entity"), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + MembershipTargetDocument::new("", MembershipTargetKind::Language), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = mixed_documents(); + assert_eq!( + execute_membership_target_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + let mut mismatched = request.clone(); + mismatched.knowledge_cutoff = "2026-07-01T00:00:00Z".into(); + assert_eq!( + execute_membership_target_run( + &mismatched, + &accepted(&mismatched), + "snapshot-membership-target", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + for profile in [ + "trsl_topic_lineage_v1", + "fitted_candidate_k_v1", + "pareto_candidate_k_v1", + "joint_posterior_draws_v1", + "method_effects_v1", + "copy_identity_v1", + "style_source_v1", + "prompt_source_v1", + "modality_source_v1", + "corpus_background_v1", + "citation_edge_v1", + "copied_text_v1", + "lineage_criterion_v1", + "composed_fitted_lineage_v1", + "case_deletion_refit_v1", + "topic_activity_v1", + "location_membership_v1", + "topic_context_posterior_v1", + "membership_posterior_icc_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_membership_target_run( + &reused, + &accepted(&reused), + "snapshot-membership-target", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..95aedace5 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-target analysis-run profile | ADR 0003/0022/0069; MembershipTargetKind language/episode/template/department/opportunity-pool are not entity/project | `analysis_engine` `membership_target_v1` binds `refuse_collapsed_target`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not location-membership, not membership-posterior ICC, not GPU, not MCMC, not topic birth/split/merge; 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/0069-membership-target-analysis-run.md b/docs/adr/0069-membership-target-analysis-run.md new file mode 100644 index 000000000..d27bbf6fb --- /dev/null +++ b/docs/adr/0069-membership-target-analysis-run.md @@ -0,0 +1,97 @@ +# ADR 0069 — Membership-target refusals as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0003 (language, episode, template, department, and opportunity-pool memberships are typed targets, not entity/project columns) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0068 (topic-context posterior), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0063 (lineage-criterion fitting), or ADR 0058 (copy-identity / template-copy). +**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 refuses to collapse one membership-target kind into +another via `membership_target::MembershipTargetKind` and +`refuse_collapsed_target`. Persistence currently stores only an entity or +a project. Operators still cannot request that typed-target census as a +digest-bound analysis-run output. + +Location-membership (#430 / ADR 0066) refuses location as entity identity +or as a language channel and does not bind `membership_target`. +Membership-posterior ICC (#398) binds a psychometric ICC estimator. +Copied-text (#427 / ADR 0065) binds unique-content/stopword vocabulary. +Copy-identity (#416 / ADR 0058) binds template-copy identity. + +`identity_recovery_rate` stays library-side. This slice does not put a +`scientific_acceptance` metric on inspect payloads. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `membership_target_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `MembershipTargetDocument` rows with closed + `MembershipTargetKind` values; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- invokes `refuse_collapsed_target` without reimplementing the + language/episode/template/department/opportunity-pool/entity/project + vocabulary; +- requires at least one typed non-entity/project kind and at least one + entity or project treatment so the census is mixed; +- emits a canonical SHA-256-digested `tepp.membership_target.v1` artifact + with per-kind counts, matching refusal counts, and inference status + `language_episode_template_department_opportunity_pool_are_not_entities`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate location-membership (#430 / ADR 0066) — rejected because + that profile binds `location_membership` LocationKind refusals, not + `MembershipTargetKind`. +2. Duplicate membership-posterior ICC (#398) — rejected because that + profile binds a psychometric ICC estimator and does not bind + `membership_target`. +3. Duplicate copied-text (#427) or copy-identity (#416) — rejected + because those profiles bind residue/template identity, not typed + membership-target kinds. +4. Put `identity_recovery_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and + `tepp.scientific_acceptance.v1` never appears. +5. Bind the existing membership-target refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe membership-target refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, location-membership, membership-posterior ICC, copied-text, +copy-identity, citation-edge, corpus-background, method-effect +estimation, or topic birth/split/merge. Snapshot/profile/cutoff +mismatch, empty or single-class corpora, and duplicate document +identities fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed +language/episode/template/department/opportunity-pool/entity/project +corpora, empty/single-class/duplicate refusal, snapshot / profile / +cutoff mismatch, and artifact tampering. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py +``` + +## Rollback and supersession + +Rollback removes the `membership_target_v1` profile. No persisted +schema migration is introduced. Supersede only with an ADR that keeps +language, episode, template, department, and opportunity-pool targets +distinct from entity/project columns and from `identity_recovery_rate` +inspect metrics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..13d9aa8d8 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. | +| [0069](0069-membership-target-analysis-run.md) | Membership-target refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `MembershipTargetKind` + `refuse_collapsed_target`, not location-membership, not membership-posterior ICC. | | [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. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **membership-target analysis-run profile:** ADR 0069. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/membership-target-analysis-run.md b/docs/doctoring/membership-target-analysis-run.md new file mode 100644 index 000000000..0b36ae808 --- /dev/null +++ b/docs/doctoring/membership-target-analysis-run.md @@ -0,0 +1,16 @@ +# Membership-target analysis-run composition + +**Active slice:** ADR 0069 / `membership_target_v1` +**Protected-main status:** not implemented-main + +`membership_target` already refuses to collapse language, episode, +template, department, or opportunity-pool kinds into entity or project +columns. This slice binds `MembershipTargetKind` and +`refuse_collapsed_target` to a cutoff-safe analysis-run profile so +operators can request a digest-bound identity artifact. + +The artifact inference status is +`language_episode_template_department_opportunity_pool_are_not_entities`. +`identity_recovery_rate` stays library-side. This is not +location-membership, not membership-posterior ICC, not copied-text, not +copy-identity, not GPU, not MCMC, and not topic birth/split/merge. From d3fa7276d4e0f0382d2c65e5404438ef26bdf67a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:09:57 +0900 Subject: [PATCH 2/3] test(analysis): bound membership-target profile and artifact --- .../membership_target_execution_contract.rs | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 6d67140d5..2f79fd7ba 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -1,9 +1,9 @@ //! End-to-end contract for cutoff-safe membership-target refusals. use analysis_engine::{ - execute_membership_target_run, AnalysisEngineError, MembershipTargetDocument, - MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, - MEMBERSHIP_TARGET_OUTPUT_PROFILE, + execute_membership_target_run, AnalysisEngineError, MembershipTargetArtifact, + MembershipTargetDocument, MAX_EVIDENCE_UNITS, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, MEMBERSHIP_TARGET_OUTPUT_PROFILE, }; use membership_target::MembershipTargetKind; use temporal_core::KnowledgeCutoff; @@ -101,6 +101,38 @@ fn mixed_target_kinds_emit_digest_bound_refusals_without_recovery_metric() { ); } +#[test] +fn compact_oversized_artifact_counts_fail_closed() { + let document_count = MAX_EVIDENCE_UNITS as u64 + 1; + let language_count = document_count - 2; + let artifact = MembershipTargetArtifact { + schema_version: MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-compact-oversize".into(), + snapshot_id: "snapshot-compact-oversize".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count, + language_count, + episode_count: 0, + template_count: 0, + department_count: 0, + opportunity_pool_count: 0, + entity_count: 1, + project_count: 1, + refused_as_entity_count: language_count, + refused_as_project_count: language_count, + inference_status: "language_episode_template_department_opportunity_pool_are_not_entities".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidMembershipTargetArtifact) + ); + assert_eq!( + MembershipTargetArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidMembershipTargetArtifact) + ); +} + #[test] fn empty_single_class_and_duplicate_identities_fail_closed() { let request = request(); @@ -163,7 +195,7 @@ fn empty_single_class_and_duplicate_identities_fail_closed() { } #[test] -fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { let request = request(); let documents = mixed_documents(); assert_eq!( @@ -225,4 +257,18 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { Err(AnalysisEngineError::InvalidEvidence) ); } + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + let kind = if index == MAX_EVIDENCE_UNITS { + MembershipTargetKind::Entity + } else { + MembershipTargetKind::Language + }; + MembershipTargetDocument::new(format!("document-{index}"), kind).expect("document") + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); } From a976c99255073c5fe08b4d850833e068fca11110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:12:39 +0900 Subject: [PATCH 3/3] fix(analysis): enforce membership-target execution bound --- .../src/membership_target_artifact.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 2e4224c35..8d9981d09 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -8,7 +8,10 @@ use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; -use crate::{format_digest, require_receipt_identity, valid_identifier, AnalysisEngineError}; +use crate::{ + format_digest, require_receipt_identity, valid_identifier, AnalysisEngineError, + MAX_EVIDENCE_UNITS, +}; /// Versioned schema for a completed membership-target artifact. pub const MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION: &str = "tepp.membership_target.v1"; @@ -152,6 +155,7 @@ impl MembershipTargetArtifact { || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() || self.document_count < 2 + || self.document_count > MAX_EVIDENCE_UNITS as u64 || typed_sum == Some(0) || persistence_sum == Some(0) || kind_sum != Some(self.document_count) @@ -185,7 +189,8 @@ pub struct MembershipTargetExecution { /// # Errors /// /// Returns a request/receipt/snapshot/cutoff/profile error, empty or -/// single-class corpus, duplicate document identity, or invalid artifact error. +/// single-class corpus, duplicate document identity, an oversized corpus, or +/// an invalid artifact error. #[allow(clippy::too_many_lines)] pub fn execute_membership_target_run( request: &AnalysisRunRequest, @@ -207,6 +212,9 @@ pub fn execute_membership_target_run( { return Err(AnalysisEngineError::InvalidEvidence); } + if documents.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } let mut seen = std::collections::BTreeSet::new(); let mut language_count = 0_u64;