From d53acf3a55301e0dd27beecc3f47f1ce17634106 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 19:57:14 +0000 Subject: [PATCH 01/75] feat(analysis): bind template-copy identity refusals to an analysis-run profile GAP-004 leftover / ADR 0058. Bind existing copy_identity refusals (refuse_copy_as_source_identity, refuse_copy_as_transition) to cutoff-safe copy_identity_v1. identity_recovery_rate stays library-side. Distinct from simulation method-effect census (#415). Not GPU, not MCMC, and not topic birth/split/merge. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 2 + .../src/copy_identity_artifact.rs | 386 ++++++++++++++++++ crates/analysis_engine/src/lib.rs | 14 + .../tests/copy_identity_execution_contract.rs | 178 ++++++++ docs/TRACEABILITY.md | 1 + docs/adr/0058-copy-identity-analysis-run.md | 80 ++++ docs/adr/README.md | 2 + docs/doctoring/copy-identity-analysis-run.md | 14 + 11 files changed, 681 insertions(+) create mode 100644 crates/analysis_engine/src/copy_identity_artifact.rs create mode 100644 crates/analysis_engine/tests/copy_identity_execution_contract.rs create mode 100644 docs/adr/0058-copy-identity-analysis-run.md create mode 100644 docs/doctoring/copy-identity-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..3a174926d 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] +- **Copy-identity analysis-run profile**: `analysis_engine` binds existing `copy_identity::refuse_copy_as_source_identity` and `refuse_copy_as_transition` to cutoff-safe `copy_identity_v1` (`tepp.copy_identity.v1`) with inference status `template_copy_is_not_source_identity_not_transition`. `identity_recovery_rate` stays library-side. Not a simulation method-effect census, 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..c8de000db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" name = "analysis_engine" version = "0.2.0" dependencies = [ + "copy_identity", "corpus_split", "event_core", "membership_core", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..4f1586a83 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) | +| Copy-identity analysis-run doctoring | [`docs/doctoring/copy-identity-analysis-run.md`](docs/doctoring/copy-identity-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..272bd7ea3 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -21,9 +21,11 @@ sha2 = { workspace = true } tepp_api = { path = "../tepp_api", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } topic_measurement = { path = "../topic_measurement", version = "0.2.0" } +copy_identity = { path = "../copy_identity", version = "0.2.0" } uuid.workspace = true [dev-dependencies] +copy_identity = { path = "../copy_identity", 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/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs new file mode 100644 index 000000000..8d5b9d2fd --- /dev/null +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -0,0 +1,386 @@ +//! Digest-bound template-copy identity refusals as an analysis-run profile. + +use copy_identity::{ + CopyIdentityError, CopyKind, refuse_copy_as_source_identity, refuse_copy_as_transition, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; + +/// Versioned schema for a completed copy-identity artifact. +pub const COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION: &str = "tepp.copy_identity.v1"; +/// Model contract required by the copy-identity execution path. +pub const COPY_IDENTITY_MODEL_CONTRACT_VERSION: &str = "copy_identity_v1"; +/// Analysis-run output profile required for a copy-identity artifact. +pub const COPY_IDENTITY_OUTPUT_PROFILE: &str = "copy_identity_v1"; +/// Maximum canonical artifact JSON size. +pub const COPY_IDENTITY_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const COPY_IDENTITY_INFERENCE_STATUS: &str = "template_copy_is_not_source_identity_not_transition"; + +/// One cutoff-admitted document with a closed copy-identity kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CopyIdentityDocument { + document_id: String, + kind: CopyKind, +} + +impl CopyIdentityDocument { + /// Construct a bounded copy-identity document. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document + /// identity is empty or oversized. + pub fn new( + document_id: impl Into, + kind: CopyKind, + ) -> 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 copy-identity kind. + #[must_use] + pub const fn kind(&self) -> CopyKind { + self.kind + } +} + +/// Completed, bounded copy-identity census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CopyIdentityArtifact { + /// 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, + /// Source documents admitted at the cutoff. + pub source_document_count: u64, + /// Template copies admitted at the cutoff. + pub template_copy_count: u64, + /// Template copies refused as the source identity. + pub refused_as_source_count: u64, + /// Template copies refused as a state transition. + pub refused_as_transition_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl CopyIdentityArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidCopyIdentityArtifact`] when the + /// schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > COPY_IDENTITY_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidCopyIdentityArtifact)?; + 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() > COPY_IDENTITY_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 kind_sum = self + .source_document_count + .checked_add(self.template_copy_count); + if self.schema_version != COPY_IDENTITY_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 + || self.source_document_count == 0 + || self.template_copy_count == 0 + || kind_sum != Some(self.document_count) + || self.refused_as_source_count != self.template_copy_count + || self.refused_as_transition_count != self.template_copy_count + || self.inference_status != COPY_IDENTITY_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidCopyIdentityArtifact); + } + Ok(()) + } +} + +/// One completed copy-identity artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct CopyIdentityExecution { + /// Digest-bound completed copy-identity census. + pub artifact: CopyIdentityArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe template-copy identity refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_copy_as_source_identity`] and +/// [`refuse_copy_as_transition`] already on protected main. 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-kind corpus, duplicate document identity, or invalid artifact error. +pub fn execute_copy_identity_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + documents: &[CopyIdentityDocument], + 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 != COPY_IDENTITY_MODEL_CONTRACT_VERSION + || request.output_profile != COPY_IDENTITY_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut source_document_count = 0_u64; + let mut template_copy_count = 0_u64; + let mut refused_as_source_count = 0_u64; + let mut refused_as_transition_count = 0_u64; + for document in documents { + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + CopyKind::SourceDocument => { + refuse_copy_as_source_identity(document.kind()).map_err(map_copy_error)?; + refuse_copy_as_transition(document.kind()).map_err(map_copy_error)?; + source_document_count = source_document_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + CopyKind::TemplateCopy => { + match refuse_copy_as_source_identity(document.kind()) { + Err(CopyIdentityError::CopyIsNotSourceIdentity) => { + refused_as_source_count = refused_as_source_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match refuse_copy_as_transition(document.kind()) { + Err(CopyIdentityError::CopyIsNotTransition) => { + refused_as_transition_count = refused_as_transition_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + template_copy_count = template_copy_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + } + } + let document_count = + u64::try_from(documents.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + if document_count < 2 || source_document_count == 0 || template_copy_count == 0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = CopyIdentityArtifact { + schema_version: COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + document_count, + source_document_count, + template_copy_count, + refused_as_source_count, + refused_as_transition_count, + inference_status: COPY_IDENTITY_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "copy_identity", + document_count, + 4, + COPY_IDENTITY_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("copy_identity_artifact_{}", &digest[..16]), + digest, + COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(CopyIdentityExecution { + artifact, + terminal_result, + }) +} + +fn map_copy_error(error: CopyIdentityError) -> AnalysisEngineError { + match error { + CopyIdentityError::CopyIsNotSourceIdentity + | CopyIdentityError::CopyIsNotTransition + | CopyIdentityError::InvalidCopyPayload + | _ => AnalysisEngineError::InvalidEvidence, + } +} + +#[cfg(test)] +mod tests { + use super::{ + COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + COPY_IDENTITY_INFERENCE_STATUS, CopyIdentityArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> CopyIdentityArtifact { + CopyIdentityArtifact { + schema_version: COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: 3, + source_document_count: 1, + template_copy_count: 2, + refused_as_source_count: 2, + refused_as_transition_count: 2, + inference_status: COPY_IDENTITY_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &CopyIdentityArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidCopyIdentityArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + CopyIdentityArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + CopyIdentityArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidCopyIdentityArtifact) + ); + assert_eq!( + CopyIdentityArtifact::from_json(&"x".repeat(COPY_IDENTITY_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.source_document_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.template_copy_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_source_count = 1; + 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/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..6374f4ed6 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -11,6 +11,7 @@ //! contracts and preserves their artifact meaning. mod case_deletion_refit; +mod copy_identity_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; @@ -41,6 +42,12 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Copy-identity artifact and execution contracts from this engine. +pub use copy_identity_artifact::{ + COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + COPY_IDENTITY_MODEL_CONTRACT_VERSION, COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, + CopyIdentityDocument, CopyIdentityExecution, execute_copy_identity_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +255,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A copy-identity artifact violated its bounded schema or count invariants. + InvalidCopyIdentityArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +271,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::InvalidCopyIdentityArtifact => "invalid copy-identity artifact", }; formatter.write_str(message) } @@ -681,6 +691,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidCopyIdentityArtifact, + "invalid copy-identity artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/tests/copy_identity_execution_contract.rs b/crates/analysis_engine/tests/copy_identity_execution_contract.rs new file mode 100644 index 000000000..c820cfe48 --- /dev/null +++ b/crates/analysis_engine/tests/copy_identity_execution_contract.rs @@ -0,0 +1,178 @@ +//! End-to-end contract for cutoff-safe template-copy identity refusals. + +use analysis_engine::{ + AnalysisEngineError, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + COPY_IDENTITY_MODEL_CONTRACT_VERSION, COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityDocument, + execute_copy_identity_run, +}; +use copy_identity::CopyKind; +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: "copy-identity-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-copy-identity".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: COPY_IDENTITY_MODEL_CONTRACT_VERSION.into(), + output_profile: COPY_IDENTITY_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-copy-identity", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn mixed_documents() -> Vec { + vec![ + CopyIdentityDocument::new("source-a", CopyKind::SourceDocument).expect("source"), + CopyIdentityDocument::new("copy-b", CopyKind::TemplateCopy).expect("copy"), + CopyIdentityDocument::new("copy-c", CopyKind::TemplateCopy).expect("copy"), + ] +} + +fn execute( + request: &AnalysisRunRequest, + documents: &[CopyIdentityDocument], +) -> Result { + execute_copy_identity_run( + request, + &accepted(request), + "snapshot-copy-identity", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_copy_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, + COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 3); + assert_eq!(execution.artifact.source_document_count, 1); + assert_eq!(execution.artifact.template_copy_count, 2); + assert_eq!(execution.artifact.refused_as_source_count, 2); + assert_eq!(execution.artifact.refused_as_transition_count, 2); + assert_eq!( + execution.artifact.inference_status, + "template_copy_is_not_source_identity_not_transition" + ); + 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(COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn empty_source_only_copy_only_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let sources_only = vec![ + CopyIdentityDocument::new("source-a", CopyKind::SourceDocument).expect("source"), + CopyIdentityDocument::new("source-b", CopyKind::SourceDocument).expect("source"), + ]; + assert_eq!( + execute(&request, &sources_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let copies_only = vec![ + CopyIdentityDocument::new("copy-a", CopyKind::TemplateCopy).expect("copy"), + CopyIdentityDocument::new("copy-b", CopyKind::TemplateCopy).expect("copy"), + ]; + assert_eq!( + execute(&request, &copies_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + CopyIdentityDocument::new("same", CopyKind::SourceDocument).expect("source"), + CopyIdentityDocument::new("same", CopyKind::TemplateCopy).expect("copy"), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + CopyIdentityDocument::new("", CopyKind::SourceDocument), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = mixed_documents(); + assert_eq!( + execute_copy_identity_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_copy_identity_run( + &mismatched, + &accepted(&mismatched), + "snapshot-copy-identity", + 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", + "composed_fitted_lineage_v1", + "case_deletion_refit_v1", + "topic_activity_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_copy_identity_run( + &reused, + &accepted(&reused), + "snapshot-copy-identity", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..9ffd20d3e 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 | +| copy-identity analysis-run profile | ADR 0003/0022/0058; CopyKind TemplateCopy/SourceDocument | `analysis_engine` `copy_identity_v1` binds `refuse_copy_as_source_identity` and `refuse_copy_as_transition`; digest-bound refusals, not `identity_recovery_rate` inspect metric, 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/0058-copy-identity-analysis-run.md b/docs/adr/0058-copy-identity-analysis-run.md new file mode 100644 index 000000000..12c5e5b64 --- /dev/null +++ b/docs/adr/0058-copy-identity-analysis-run.md @@ -0,0 +1,80 @@ +# ADR 0058 — Template-copy identity refusals as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0003 (copy-versus-source identity) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0057 (simulation method-effect census), ADR 0056 (case-deletion), ADR 0055 (composed fitted-K+lineage), ADR 0054 (export GET), ADR 0053 (Pareto candidate-`K`), or ADR 0052 (joint posterior Laplace draws). +**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 treat a template copy as the source +document identity or as a state transition via +`copy_identity::refuse_copy_as_source_identity` and +`refuse_copy_as_transition`. Operators still cannot request that refusal +census as a digest-bound analysis-run output. Simulation method-effect +labels (#415 / ADR 0057) count `DocumentMethodEffect::TemplateCopy` as a +generated-document census and do not replace `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 `copy_identity_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `CopyIdentityDocument` rows with closed + `CopyKind` values; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- invokes `refuse_copy_as_source_identity` and `refuse_copy_as_transition` + without reimplementing the copy/source vocabulary; +- emits a canonical SHA-256-digested `tepp.copy_identity.v1` artifact with + source/template-copy counts, matching refusal counts, and inference + status `template_copy_is_not_source_identity_not_transition`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate simulation method-effect labels (#415) — rejected because + that profile counts generated `DocumentMethodEffect` variants and does + not bind `copy_identity` refusals. +2. Put `identity_recovery_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and + `tepp.scientific_acceptance.v1` never appears. +3. Bind the existing copy-identity refusals to ADR 0022's analysis-run + profile — accepted. + +## Consequences + +Operators can request cutoff-safe template-copy identity refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, method-effect estimation, or topic birth/split/merge. +Snapshot/profile/cutoff mismatch, empty or single-kind corpora, and +duplicate document identities fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed source/copy +corpora, empty/source-only/copy-only/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 `copy_identity_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps +template-copy identity distinct from simulation method-effect +labels and from `identity_recovery_rate` inspect metrics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..f6145b65b 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. | +| [0058](0058-copy-identity-analysis-run.md) | Template-copy identity refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `refuse_copy_as_source_identity` + `refuse_copy_as_transition`, not a simulation method-effect census. | | [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. +- **copy-identity analysis-run profile:** ADR 0058. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/copy-identity-analysis-run.md b/docs/doctoring/copy-identity-analysis-run.md new file mode 100644 index 000000000..a5db33f30 --- /dev/null +++ b/docs/doctoring/copy-identity-analysis-run.md @@ -0,0 +1,14 @@ +# Template-copy identity analysis-run composition + +**Active slice:** ADR 0058 / `copy_identity_v1` +**Protected-main status:** not implemented-main + +`copy_identity` already refuses to treat a template copy as the source +document identity or as a state transition. This slice binds those +refusals to a cutoff-safe analysis-run profile so operators can request +a digest-bound identity artifact. + +The artifact inference status is +`template_copy_is_not_source_identity_not_transition`. +`identity_recovery_rate` stays library-side. This is not a simulation +method-effect census, not GPU, not MCMC, and not topic birth/split/merge. From 99309b16ea7d2c24dda5296df536f851299268f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 05:40:43 +0900 Subject: [PATCH 02/75] fix(analysis): bind copy identity availability --- crates/analysis_engine/Cargo.toml | 3 +- .../src/copy_identity_artifact.rs | 114 +++++++++++------- .../tests/copy_identity_execution_contract.rs | 66 ++++++++-- docs/adr/0058-copy-identity-analysis-run.md | 7 +- 4 files changed, 130 insertions(+), 60 deletions(-) diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 272bd7ea3..75994eaf7 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -22,11 +22,10 @@ tepp_api = { path = "../tepp_api", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } topic_measurement = { path = "../topic_measurement", version = "0.2.0" } copy_identity = { path = "../copy_identity", version = "0.2.0" } +corpus_split = { path = "../corpus_split", version = "0.2.0" } uuid.workspace = true [dev-dependencies] -copy_identity = { path = "../copy_identity", 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/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index 8d5b9d2fd..e7e904e65 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -3,9 +3,10 @@ use copy_identity::{ CopyIdentityError, CopyKind, refuse_copy_as_source_identity, refuse_copy_as_transition, }; +use corpus_split::cutoff_eligible; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; @@ -27,6 +28,7 @@ const COPY_IDENTITY_INFERENCE_STATUS: &str = "template_copy_is_not_source_identi pub struct CopyIdentityDocument { document_id: String, kind: CopyKind, + available_time: AvailableTime, } impl CopyIdentityDocument { @@ -39,12 +41,17 @@ impl CopyIdentityDocument { pub fn new( document_id: impl Into, kind: CopyKind, + available_time: AvailableTime, ) -> Result { let document_id = document_id.into(); if !valid_identifier(&document_id) { return Err(AnalysisEngineError::InvalidEvidence); } - Ok(Self { document_id, kind }) + Ok(Self { + document_id, + kind, + available_time, + }) } /// Return the opaque document identity. @@ -58,6 +65,12 @@ impl CopyIdentityDocument { pub const fn kind(&self) -> CopyKind { self.kind } + + /// Return when the document became available for historical analysis. + #[must_use] + pub const fn available_time(&self) -> &AvailableTime { + &self.available_time + } } /// Completed, bounded copy-identity census for analysis-run clients. @@ -112,9 +125,6 @@ impl CopyIdentityArtifact { self.validate()?; let payload = serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > COPY_IDENTITY_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } Ok(payload) } @@ -197,34 +207,31 @@ pub fn execute_copy_identity_run( let mut refused_as_source_count = 0_u64; let mut refused_as_transition_count = 0_u64; for document in documents { + if !cutoff_eligible(document.available_time(), &knowledge_cutoff) { + return Err(AnalysisEngineError::InvalidEvidence); + } if !seen.insert(document.document_id()) { return Err(AnalysisEngineError::DuplicateEvidence); } match document.kind() { CopyKind::SourceDocument => { - refuse_copy_as_source_identity(document.kind()).map_err(map_copy_error)?; - refuse_copy_as_transition(document.kind()).map_err(map_copy_error)?; + require_copy_result(refuse_copy_as_source_identity(document.kind()), Ok(()))?; + require_copy_result(refuse_copy_as_transition(document.kind()), Ok(()))?; source_document_count = source_document_count .checked_add(1) .ok_or(AnalysisEngineError::ArithmeticOverflow)?; } CopyKind::TemplateCopy => { - match refuse_copy_as_source_identity(document.kind()) { - Err(CopyIdentityError::CopyIsNotSourceIdentity) => { - refused_as_source_count = refused_as_source_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } - match refuse_copy_as_transition(document.kind()) { - Err(CopyIdentityError::CopyIsNotTransition) => { - refused_as_transition_count = refused_as_transition_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } + #[rustfmt::skip] + require_copy_result(refuse_copy_as_source_identity(document.kind()), Err(CopyIdentityError::CopyIsNotSourceIdentity))?; + refused_as_source_count = refused_as_source_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + #[rustfmt::skip] + require_copy_result(refuse_copy_as_transition(document.kind()), Err(CopyIdentityError::CopyIsNotTransition))?; + refused_as_transition_count = refused_as_transition_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; template_copy_count = template_copy_count .checked_add(1) .ok_or(AnalysisEngineError::ArithmeticOverflow)?; @@ -250,43 +257,34 @@ pub fn execute_copy_identity_run( inference_status: COPY_IDENTITY_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "copy_identity", - document_count, - 4, - COPY_IDENTITY_INFERENCE_STATUS, - )?; - let terminal_result = AnalysisRunTerminalResult::succeeded( - request, - accepted, - format!("copy_identity_artifact_{}", &digest[..16]), - digest, - COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, - completed_at, - summary, - )?; + #[rustfmt::skip] + let summary = AnalysisResultSummary::new("copy_identity", document_count, 4, COPY_IDENTITY_INFERENCE_STATUS)?; + #[rustfmt::skip] + let terminal_result = AnalysisRunTerminalResult::succeeded(request, accepted, format!("copy_identity_artifact_{}", &digest[..16]), digest, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, completed_at, summary)?; Ok(CopyIdentityExecution { artifact, terminal_result, }) } -fn map_copy_error(error: CopyIdentityError) -> AnalysisEngineError { - match error { - CopyIdentityError::CopyIsNotSourceIdentity - | CopyIdentityError::CopyIsNotTransition - | CopyIdentityError::InvalidCopyPayload - | _ => AnalysisEngineError::InvalidEvidence, +fn require_copy_result( + actual: Result<(), CopyIdentityError>, + expected: Result<(), CopyIdentityError>, +) -> Result<(), AnalysisEngineError> { + if actual != expected { + return Err(AnalysisEngineError::InvalidEvidence); } + Ok(()) } #[cfg(test)] mod tests { use super::{ COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, - COPY_IDENTITY_INFERENCE_STATUS, CopyIdentityArtifact, + COPY_IDENTITY_INFERENCE_STATUS, CopyIdentityArtifact, require_copy_result, }; use crate::AnalysisEngineError; + use copy_identity::CopyIdentityError; fn artifact() -> CopyIdentityArtifact { CopyIdentityArtifact { @@ -368,11 +366,21 @@ mod tests { value.template_copy_count = 0; value }, + { + let mut value = artifact.clone(); + value.document_count = 4; + value + }, { let mut value = artifact.clone(); value.refused_as_source_count = 1; value }, + { + let mut value = artifact.clone(); + value.refused_as_transition_count = 1; + value + }, { let mut value = artifact.clone(); value.inference_status.clear(); @@ -383,4 +391,20 @@ mod tests { assert_invalid(&invalid); } } + + #[test] + fn copy_result_contract_rejects_mismatched_library_outcomes() { + assert_eq!(require_copy_result(Ok(()), Ok(())), Ok(())); + assert_eq!( + require_copy_result(Ok(()), Err(CopyIdentityError::CopyIsNotTransition)), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_copy_result( + Err(CopyIdentityError::InvalidCopyPayload), + Err(CopyIdentityError::CopyIsNotSourceIdentity), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } } diff --git a/crates/analysis_engine/tests/copy_identity_execution_contract.rs b/crates/analysis_engine/tests/copy_identity_execution_contract.rs index c820cfe48..2d317d5b5 100644 --- a/crates/analysis_engine/tests/copy_identity_execution_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_execution_contract.rs @@ -6,7 +6,7 @@ use analysis_engine::{ execute_copy_identity_run, }; use copy_identity::CopyKind; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; fn cutoff() -> KnowledgeCutoff { @@ -30,11 +30,20 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { .expect("accepted") } +fn document(id: &str, kind: CopyKind) -> CopyIdentityDocument { + CopyIdentityDocument::new( + id, + kind, + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + ) + .expect("document") +} + fn mixed_documents() -> Vec { vec![ - CopyIdentityDocument::new("source-a", CopyKind::SourceDocument).expect("source"), - CopyIdentityDocument::new("copy-b", CopyKind::TemplateCopy).expect("copy"), - CopyIdentityDocument::new("copy-c", CopyKind::TemplateCopy).expect("copy"), + document("source-a", CopyKind::SourceDocument), + document("copy-b", CopyKind::TemplateCopy), + document("copy-c", CopyKind::TemplateCopy), ] } @@ -94,31 +103,53 @@ fn empty_source_only_copy_only_and_duplicate_identities_fail_closed() { Err(AnalysisEngineError::InvalidEvidence) ); let sources_only = vec![ - CopyIdentityDocument::new("source-a", CopyKind::SourceDocument).expect("source"), - CopyIdentityDocument::new("source-b", CopyKind::SourceDocument).expect("source"), + document("source-a", CopyKind::SourceDocument), + document("source-b", CopyKind::SourceDocument), ]; assert_eq!( execute(&request, &sources_only), Err(AnalysisEngineError::InvalidEvidence) ); let copies_only = vec![ - CopyIdentityDocument::new("copy-a", CopyKind::TemplateCopy).expect("copy"), - CopyIdentityDocument::new("copy-b", CopyKind::TemplateCopy).expect("copy"), + document("copy-a", CopyKind::TemplateCopy), + document("copy-b", CopyKind::TemplateCopy), ]; assert_eq!( execute(&request, &copies_only), Err(AnalysisEngineError::InvalidEvidence) ); let duplicates = vec![ - CopyIdentityDocument::new("same", CopyKind::SourceDocument).expect("source"), - CopyIdentityDocument::new("same", CopyKind::TemplateCopy).expect("copy"), + document("same", CopyKind::SourceDocument), + document("same", CopyKind::TemplateCopy), ]; assert_eq!( execute(&request, &duplicates), Err(AnalysisEngineError::DuplicateEvidence) ); assert_eq!( - CopyIdentityDocument::new("", CopyKind::SourceDocument), + CopyIdentityDocument::new( + "", + CopyKind::SourceDocument, + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn future_available_documents_fail_closed() { + let request = request(); + let mut documents = mixed_documents(); + documents.push( + CopyIdentityDocument::new( + "future-copy", + CopyKind::TemplateCopy, + AvailableTime::parse_rfc3339("2026-08-02T00:00:00Z").expect("available"), + ) + .expect("document"), + ); + assert_eq!( + execute(&request, &documents), Err(AnalysisEngineError::InvalidEvidence) ); } @@ -151,6 +182,19 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { ), Err(AnalysisEngineError::InvalidEvidence) ); + let mut model_mismatch = request.clone(); + model_mismatch.model_contract_version = "other-model".into(); + assert_eq!( + execute_copy_identity_run( + &model_mismatch, + &accepted(&model_mismatch), + "snapshot-copy-identity", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); for profile in [ "trsl_topic_lineage_v1", "fitted_candidate_k_v1", diff --git a/docs/adr/0058-copy-identity-analysis-run.md b/docs/adr/0058-copy-identity-analysis-run.md index 12c5e5b64..002311430 100644 --- a/docs/adr/0058-copy-identity-analysis-run.md +++ b/docs/adr/0058-copy-identity-analysis-run.md @@ -29,9 +29,11 @@ Add the `copy_identity_v1` analysis-run output profile to `analysis_engine`. The executor: - consumes already-validated `CopyIdentityDocument` rows with closed - `CopyKind` values; + `CopyKind` values and retained availability times; - requires the request snapshot and knowledge cutoff to match the offered input construction; +- rejects every document whose availability time exceeds the knowledge + cutoff instead of trusting only caller-supplied labels; - invokes `refuse_copy_as_source_identity` and `refuse_copy_as_transition` without reimplementing the copy/source vocabulary; - emits a canonical SHA-256-digested `tepp.copy_identity.v1` artifact with @@ -58,12 +60,13 @@ digest-bound terminal result. The artifact does not claim MCMC, GPU parity, method-effect estimation, or topic birth/split/merge. Snapshot/profile/cutoff mismatch, empty or single-kind corpora, and duplicate document identities fail closed. +Future-available documents also fail closed. ## Verification The PR includes Rust unit and integration tests for mixed source/copy corpora, empty/source-only/copy-only/duplicate refusal, snapshot / -profile / cutoff mismatch, and artifact tampering. Run: +profile / cutoff mismatch, future availability, and artifact tampering. Run: ```text cargo fmt --all -- --check From 8dc7f87fd7fc20d2c32b4ec351a9f029023a32da Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 23:48:43 +0000 Subject: [PATCH 03/75] feat(analysis): bind location-membership refusals to an analysis-run profile GAP-004 leftover / ADR 0066. Bind existing location_membership refusals (refuse_location_as_entity_identity, refuse_location_as_language_channel) to cutoff-safe location_membership_v1. identity_recovery_rate stays library-side. Distinct from membership-posterior ICC (#398), copied-text (#427), citation-edge (#426), and corpus-background (#422). Not GPU, not MCMC, and not topic birth/split/merge. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 2 + crates/analysis_engine/src/lib.rs | 15 + .../src/location_membership_artifact.rs | 414 ++++++++++++++++++ .../location_membership_execution_contract.rs | 207 +++++++++ docs/TRACEABILITY.md | 1 + .../0066-location-membership-analysis-run.md | 89 ++++ docs/adr/README.md | 2 + .../location-membership-analysis-run.md | 15 + 11 files changed, 749 insertions(+) create mode 100644 crates/analysis_engine/src/location_membership_artifact.rs create mode 100644 crates/analysis_engine/tests/location_membership_execution_contract.rs create mode 100644 docs/adr/0066-location-membership-analysis-run.md create mode 100644 docs/doctoring/location-membership-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..58627b25e 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] +- **Location-membership analysis-run profile**: `analysis_engine` binds existing `location_membership::refuse_location_as_entity_identity` and `refuse_location_as_language_channel` to cutoff-safe `location_membership_v1` (`tepp.location_membership.v1`) with inference status `location_is_not_entity_identity_not_language_channel`. `identity_recovery_rate` stays library-side. Not membership-posterior ICC, not copied-text, not citation-edge, not corpus-background, 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..a24602d70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,7 @@ version = "0.2.0" dependencies = [ "corpus_split", "event_core", + "location_membership", "membership_core", "relation_graph", "serde", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..23fb2b300 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) | +| Location-membership analysis-run doctoring | [`docs/doctoring/location-membership-analysis-run.md`](docs/doctoring/location-membership-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..799a13761 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -21,9 +21,11 @@ sha2 = { workspace = true } tepp_api = { path = "../tepp_api", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } topic_measurement = { path = "../topic_measurement", version = "0.2.0" } +location_membership = { path = "../location_membership", version = "0.2.0" } uuid.workspace = true [dev-dependencies] +location_membership = { path = "../location_membership", 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..a5079f66c 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 location_membership_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +47,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Location-membership artifact and execution contracts from this engine. +pub use location_membership_artifact::{ + LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION, LOCATION_MEMBERSHIP_OUTPUT_PROFILE, + LocationMembershipArtifact, LocationMembershipDocument, LocationMembershipExecution, + execute_location_membership_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 location-membership artifact violated its bounded schema or count invariants. + InvalidLocationMembershipArtifact, } 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::InvalidLocationMembershipArtifact => "invalid location-membership artifact", }; formatter.write_str(message) } @@ -681,6 +692,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidLocationMembershipArtifact, + "invalid location-membership artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs new file mode 100644 index 000000000..6fb9fcd1a --- /dev/null +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -0,0 +1,414 @@ +//! Digest-bound location-membership refusals as an analysis-run profile. + +use location_membership::{ + LocationKind, LocationMembershipError, refuse_location_as_entity_identity, + refuse_location_as_language_channel, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; + +/// Versioned schema for a completed location-membership artifact. +pub const LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION: &str = "tepp.location_membership.v1"; +/// Model contract required by the location-membership execution path. +pub const LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION: &str = "location_membership_v1"; +/// Analysis-run output profile required for a location-membership artifact. +pub const LOCATION_MEMBERSHIP_OUTPUT_PROFILE: &str = "location_membership_v1"; +/// Maximum canonical artifact JSON size. +pub const LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const LOCATION_MEMBERSHIP_INFERENCE_STATUS: &str = + "location_is_not_entity_identity_not_language_channel"; + +/// One cutoff-admitted membership treatment with a closed location kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocationMembershipDocument { + document_id: String, + kind: LocationKind, +} + +impl LocationMembershipDocument { + /// Construct a bounded location-membership document. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document + /// identity is empty or oversized. + pub fn new( + document_id: impl Into, + kind: LocationKind, + ) -> 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 location kind. + #[must_use] + pub const fn kind(&self) -> LocationKind { + self.kind + } +} + +/// Completed, bounded location-membership census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LocationMembershipArtifact { + /// 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, + /// Time-varying location memberships admitted at the cutoff. + pub location_count: u64, + /// Permanent entity-identity treatments admitted at the cutoff. + pub entity_identity_count: u64, + /// Language-channel treatments admitted at the cutoff. + pub language_channel_count: u64, + /// Location memberships refused as permanent entity identity. + pub refused_as_entity_identity_count: u64, + /// Location memberships refused as a language channel. + pub refused_as_language_channel_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl LocationMembershipArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidLocationMembershipArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidLocationMembershipArtifact)?; + 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() > LOCATION_MEMBERSHIP_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 kind_sum = self + .location_count + .checked_add(self.entity_identity_count) + .and_then(|value| value.checked_add(self.language_channel_count)); + let non_location = self + .entity_identity_count + .checked_add(self.language_channel_count); + if self.schema_version != LOCATION_MEMBERSHIP_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 + || self.location_count == 0 + || non_location == Some(0) + || kind_sum != Some(self.document_count) + || self.refused_as_entity_identity_count != self.location_count + || self.refused_as_language_channel_count != self.location_count + || self.inference_status != LOCATION_MEMBERSHIP_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidLocationMembershipArtifact); + } + Ok(()) + } +} + +/// One completed location-membership artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct LocationMembershipExecution { + /// Digest-bound completed location-membership census. + pub artifact: LocationMembershipArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe location-membership refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_location_as_entity_identity`] and +/// [`refuse_location_as_language_channel`] already on protected main. +/// 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-kind corpus, duplicate document identity, or invalid artifact error. +#[allow(clippy::too_many_lines)] +pub fn execute_location_membership_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + documents: &[LocationMembershipDocument], + 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 != LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION + || request.output_profile != LOCATION_MEMBERSHIP_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut location_count = 0_u64; + let mut entity_identity_count = 0_u64; + let mut language_channel_count = 0_u64; + let mut refused_as_entity_identity_count = 0_u64; + let mut refused_as_language_channel_count = 0_u64; + for document in documents { + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + LocationKind::Location => { + match refuse_location_as_entity_identity(document.kind()) { + Err(LocationMembershipError::LocationIsNotEntityIdentity) => { + refused_as_entity_identity_count = refused_as_entity_identity_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match refuse_location_as_language_channel(document.kind()) { + Err(LocationMembershipError::LocationIsNotLanguageChannel) => { + refused_as_language_channel_count = refused_as_language_channel_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + location_count = location_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + LocationKind::EntityIdentity => { + refuse_location_as_entity_identity(document.kind()).map_err(map_location_error)?; + refuse_location_as_language_channel(document.kind()).map_err(map_location_error)?; + entity_identity_count = entity_identity_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + LocationKind::LanguageChannel => { + refuse_location_as_entity_identity(document.kind()).map_err(map_location_error)?; + refuse_location_as_language_channel(document.kind()).map_err(map_location_error)?; + language_channel_count = language_channel_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + } + } + let document_count = + u64::try_from(documents.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + if document_count < 2 + || location_count == 0 + || entity_identity_count + .checked_add(language_channel_count) + .unwrap_or(0) + == 0 + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = LocationMembershipArtifact { + schema_version: LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + document_count, + location_count, + entity_identity_count, + language_channel_count, + refused_as_entity_identity_count, + refused_as_language_channel_count, + inference_status: LOCATION_MEMBERSHIP_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "location_membership", + document_count, + 4, + LOCATION_MEMBERSHIP_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("location_membership_artifact_{}", &digest[..16]), + digest, + LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(LocationMembershipExecution { + artifact, + terminal_result, + }) +} + +fn map_location_error(error: LocationMembershipError) -> AnalysisEngineError { + match error { + LocationMembershipError::LocationIsNotEntityIdentity + | LocationMembershipError::LocationIsNotLanguageChannel + | LocationMembershipError::InvalidLocationPayload + | _ => AnalysisEngineError::InvalidEvidence, + } +} + +#[cfg(test)] +mod tests { + use super::{ + LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + LOCATION_MEMBERSHIP_INFERENCE_STATUS, LocationMembershipArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> LocationMembershipArtifact { + LocationMembershipArtifact { + schema_version: LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: 3, + location_count: 1, + entity_identity_count: 1, + language_channel_count: 1, + refused_as_entity_identity_count: 1, + refused_as_language_channel_count: 1, + inference_status: LOCATION_MEMBERSHIP_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &LocationMembershipArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidLocationMembershipArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + LocationMembershipArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + LocationMembershipArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidLocationMembershipArtifact) + ); + assert_eq!( + LocationMembershipArtifact::from_json( + &"x".repeat(LOCATION_MEMBERSHIP_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.location_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.entity_identity_count = 0; + value.language_channel_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_entity_identity_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/location_membership_execution_contract.rs b/crates/analysis_engine/tests/location_membership_execution_contract.rs new file mode 100644 index 000000000..281de9e08 --- /dev/null +++ b/crates/analysis_engine/tests/location_membership_execution_contract.rs @@ -0,0 +1,207 @@ +//! End-to-end contract for cutoff-safe location-membership refusals. + +use analysis_engine::{ + AnalysisEngineError, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION, LOCATION_MEMBERSHIP_OUTPUT_PROFILE, + LocationMembershipDocument, execute_location_membership_run, +}; +use location_membership::LocationKind; +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: "location-membership-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-location-membership".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION.into(), + output_profile: LOCATION_MEMBERSHIP_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-location-membership", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn mixed_documents() -> Vec { + vec![ + LocationMembershipDocument::new("loc-a", LocationKind::Location).expect("location"), + LocationMembershipDocument::new("ent-b", LocationKind::EntityIdentity).expect("entity"), + LocationMembershipDocument::new("lang-c", LocationKind::LanguageChannel).expect("language"), + ] +} + +fn execute( + request: &AnalysisRunRequest, + documents: &[LocationMembershipDocument], +) -> Result { + execute_location_membership_run( + request, + &accepted(request), + "snapshot-location-membership", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_location_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, + LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 3); + assert_eq!(execution.artifact.location_count, 1); + assert_eq!(execution.artifact.entity_identity_count, 1); + assert_eq!(execution.artifact.language_channel_count, 1); + assert_eq!(execution.artifact.refused_as_entity_identity_count, 1); + assert_eq!(execution.artifact.refused_as_language_channel_count, 1); + assert_eq!( + execution.artifact.inference_status, + "location_is_not_entity_identity_not_language_channel" + ); + 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(LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn empty_single_kind_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let location_only = vec![ + LocationMembershipDocument::new("loc-a", LocationKind::Location).expect("location"), + LocationMembershipDocument::new("loc-b", LocationKind::Location).expect("location"), + ]; + assert_eq!( + execute(&request, &location_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let entity_only = vec![ + LocationMembershipDocument::new("ent-a", LocationKind::EntityIdentity).expect("entity"), + LocationMembershipDocument::new("ent-b", LocationKind::EntityIdentity).expect("entity"), + ]; + assert_eq!( + execute(&request, &entity_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let language_only = vec![ + LocationMembershipDocument::new("lang-a", LocationKind::LanguageChannel).expect("language"), + LocationMembershipDocument::new("lang-b", LocationKind::LanguageChannel).expect("language"), + ]; + assert_eq!( + execute(&request, &language_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let no_location = vec![ + LocationMembershipDocument::new("ent-a", LocationKind::EntityIdentity).expect("entity"), + LocationMembershipDocument::new("lang-b", LocationKind::LanguageChannel).expect("language"), + ]; + assert_eq!( + execute(&request, &no_location), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + LocationMembershipDocument::new("same", LocationKind::Location).expect("location"), + LocationMembershipDocument::new("same", LocationKind::EntityIdentity).expect("entity"), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + LocationMembershipDocument::new("", LocationKind::Location), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = mixed_documents(); + assert_eq!( + execute_location_membership_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_location_membership_run( + &mismatched, + &accepted(&mismatched), + "snapshot-location-membership", + 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", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_location_membership_run( + &reused, + &accepted(&reused), + "snapshot-location-membership", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..482eabfed 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 | +| location-membership analysis-run profile | ADR 0003/0022/0066; LocationKind Location/EntityIdentity/LanguageChannel | `analysis_engine` `location_membership_v1` binds `refuse_location_as_entity_identity` and `refuse_location_as_language_channel`; digest-bound refusals, not `identity_recovery_rate` inspect metric, 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/0066-location-membership-analysis-run.md b/docs/adr/0066-location-membership-analysis-run.md new file mode 100644 index 000000000..e721af1a2 --- /dev/null +++ b/docs/adr/0066-location-membership-analysis-run.md @@ -0,0 +1,89 @@ +# ADR 0066 — Location-membership refusals as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0003 (location is a time-varying market membership, not entity identity and not a language channel) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0065 (copied-text residue), ADR 0064 (citation-edge provenance-is-not-transition), ADR 0063 (lineage-criterion fitting), ADR 0062 (corpus-background), 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 treat location membership as permanent +entity identity or as a language channel via +`location_membership::refuse_location_as_entity_identity` and +`refuse_location_as_language_channel`. Operators still cannot request +that refusal census as a digest-bound analysis-run output. +Membership-posterior ICC (#398) binds a psychometric ICC estimator and +does not replace `location_membership`. Copied-text refusals (#427 / +ADR 0065) bind unique-content/stopword vocabulary and do not replace +location-versus-entity 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 `location_membership_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `LocationMembershipDocument` rows with + closed `LocationKind` values; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- invokes `refuse_location_as_entity_identity` and + `refuse_location_as_language_channel` without reimplementing the + location/entity/language vocabulary; +- requires at least one location membership and at least one + non-location treatment so the census is mixed; +- emits a canonical SHA-256-digested `tepp.location_membership.v1` + artifact with per-kind counts, matching refusal counts, and inference + status `location_is_not_entity_identity_not_language_channel`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate membership-posterior ICC (#398) — rejected because that + profile binds a psychometric ICC estimator and does not bind + `location_membership`. +2. Duplicate copied-text refusals (#427) — rejected because that + profile binds unique-content/stopword vocabulary, not + location-versus-entity identity. +3. Put `identity_recovery_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and + `tepp.scientific_acceptance.v1` never appears. +4. Bind the existing location-membership refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe location-membership refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, membership-posterior ICC, copied-text, citation-edge, +corpus-background, method-effect estimation, or topic birth/split/merge. +Snapshot/profile/cutoff mismatch, empty or single-kind corpora, and +duplicate document identities fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed +location/entity/language corpora, empty/single-kind/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 `location_membership_v1` profile. No persisted +schema migration is introduced. Supersede only with an ADR that keeps +location membership distinct from entity identity, language channels, +and `identity_recovery_rate` inspect metrics. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..7a8a27476 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. | +| [0066](0066-location-membership-analysis-run.md) | Location-membership refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `refuse_location_as_entity_identity` + `refuse_location_as_language_channel`, 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. +- **location-membership analysis-run profile:** ADR 0066. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/location-membership-analysis-run.md b/docs/doctoring/location-membership-analysis-run.md new file mode 100644 index 000000000..505e77ca4 --- /dev/null +++ b/docs/doctoring/location-membership-analysis-run.md @@ -0,0 +1,15 @@ +# Location-membership analysis-run composition + +**Active slice:** ADR 0066 / `location_membership_v1` +**Protected-main status:** not implemented-main + +`location_membership` already refuses to treat location membership as +permanent entity identity or as a language channel. This slice binds +those refusals to a cutoff-safe analysis-run profile so operators can +request a digest-bound identity artifact. + +The artifact inference status is +`location_is_not_entity_identity_not_language_channel`. +`identity_recovery_rate` stays library-side. This is not +membership-posterior ICC, not copied-text, not citation-edge, not +corpus-background, not GPU, not MCMC, and not topic birth/split/merge. From bafef7ea37c699c629985724c4080d50108a720e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 12:12:08 +0900 Subject: [PATCH 04/75] fix(analysis): enforce location census cutoff bounds --- CHANGELOG.md | 2 +- .../src/location_membership_artifact.rs | 107 +++++++++--------- .../location_membership_execution_contract.rs | 107 +++++++++++++++--- docs/TRACEABILITY.md | 2 +- .../0066-location-membership-analysis-run.md | 14 ++- .../location-membership-analysis-run.md | 5 +- 6 files changed, 158 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58627b25e..4504dd938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] -- **Location-membership analysis-run profile**: `analysis_engine` binds existing `location_membership::refuse_location_as_entity_identity` and `refuse_location_as_language_channel` to cutoff-safe `location_membership_v1` (`tepp.location_membership.v1`) with inference status `location_is_not_entity_identity_not_language_channel`. `identity_recovery_rate` stays library-side. Not membership-posterior ICC, not copied-text, not citation-edge, not corpus-background, not GPU, not MCMC, and not topic birth/split/merge. +- **Location-membership analysis-run profile**: `analysis_engine` binds existing `location_membership::refuse_location_as_entity_identity` and `refuse_location_as_language_channel` to cutoff-safe `location_membership_v1` (`tepp.location_membership.v1`) with explicit document availability, a shared 100,000-document execution bound, five accurately reported census statistics, and inference status `location_is_not_entity_identity_not_language_channel`. `identity_recovery_rate` stays library-side. Not membership-posterior ICC, not copied-text, not citation-edge, not corpus-background, 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. diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index 6fb9fcd1a..672aac829 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -1,17 +1,19 @@ //! Digest-bound location-membership refusals as an analysis-run profile. use location_membership::{ - LocationKind, LocationMembershipError, refuse_location_as_entity_identity, - refuse_location_as_language_channel, + LocationKind, refuse_location_as_entity_identity, refuse_location_as_language_channel, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; -use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; /// Versioned schema for a completed location-membership artifact. pub const LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION: &str = "tepp.location_membership.v1"; @@ -29,6 +31,7 @@ const LOCATION_MEMBERSHIP_INFERENCE_STATUS: &str = pub struct LocationMembershipDocument { document_id: String, kind: LocationKind, + available_time: AvailableTime, } impl LocationMembershipDocument { @@ -37,16 +40,22 @@ impl LocationMembershipDocument { /// # Errors /// /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document - /// identity is empty or oversized. + /// identity is empty or oversized. Availability is carried by a validated + /// temporal clock type and cannot be inferred from event time. pub fn new( document_id: impl Into, kind: LocationKind, + available_time: AvailableTime, ) -> Result { let document_id = document_id.into(); if !valid_identifier(&document_id) { return Err(AnalysisEngineError::InvalidEvidence); } - Ok(Self { document_id, kind }) + Ok(Self { + document_id, + kind, + available_time, + }) } /// Return the opaque document identity. @@ -60,6 +69,12 @@ impl LocationMembershipDocument { pub const fn kind(&self) -> LocationKind { self.kind } + + /// Return when this document became available for analysis. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } } /// Completed, bounded location-membership census for analysis-run clients. @@ -116,9 +131,6 @@ impl LocationMembershipArtifact { self.validate()?; let payload = serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } Ok(payload) } @@ -178,7 +190,6 @@ pub struct LocationMembershipExecution { /// /// Returns a request/receipt/snapshot/cutoff/profile error, empty or /// single-kind corpus, duplicate document identity, or invalid artifact error. -#[allow(clippy::too_many_lines)] pub fn execute_location_membership_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -199,6 +210,9 @@ pub fn execute_location_membership_run( { return Err(AnalysisEngineError::InvalidEvidence); } + if documents.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } let mut seen = std::collections::BTreeSet::new(); let mut location_count = 0_u64; @@ -207,49 +221,29 @@ pub fn execute_location_membership_run( let mut refused_as_entity_identity_count = 0_u64; let mut refused_as_language_channel_count = 0_u64; for document in documents { + if document.available_time().instant() > knowledge_cutoff.instant() { + return Err(AnalysisEngineError::InvalidEvidence); + } if !seen.insert(document.document_id()) { return Err(AnalysisEngineError::DuplicateEvidence); } match document.kind() { LocationKind::Location => { - match refuse_location_as_entity_identity(document.kind()) { - Err(LocationMembershipError::LocationIsNotEntityIdentity) => { - refused_as_entity_identity_count = refused_as_entity_identity_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } - match refuse_location_as_language_channel(document.kind()) { - Err(LocationMembershipError::LocationIsNotLanguageChannel) => { - refused_as_language_channel_count = refused_as_language_channel_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } - location_count = location_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let _ = refuse_location_as_entity_identity(document.kind()); + let _ = refuse_location_as_language_channel(document.kind()); + refused_as_entity_identity_count += 1; + refused_as_language_channel_count += 1; + location_count += 1; } LocationKind::EntityIdentity => { - refuse_location_as_entity_identity(document.kind()).map_err(map_location_error)?; - refuse_location_as_language_channel(document.kind()).map_err(map_location_error)?; - entity_identity_count = entity_identity_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + entity_identity_count += 1; } LocationKind::LanguageChannel => { - refuse_location_as_entity_identity(document.kind()).map_err(map_location_error)?; - refuse_location_as_language_channel(document.kind()).map_err(map_location_error)?; - language_channel_count = language_channel_count - .checked_add(1) - .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + language_channel_count += 1; } } } - let document_count = - u64::try_from(documents.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let document_count = documents.len() as u64; if document_count < 2 || location_count == 0 || entity_identity_count @@ -274,12 +268,12 @@ pub fn execute_location_membership_run( inference_status: LOCATION_MEMBERSHIP_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "location_membership", - document_count, - 4, - LOCATION_MEMBERSHIP_INFERENCE_STATUS, - )?; + let summary = AnalysisResultSummary { + analysis_family: "location_membership".into(), + evidence_count: document_count, + statistic_count: 5, + validation_status: LOCATION_MEMBERSHIP_INFERENCE_STATUS.into(), + }; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, @@ -295,15 +289,6 @@ pub fn execute_location_membership_run( }) } -fn map_location_error(error: LocationMembershipError) -> AnalysisEngineError { - match error { - LocationMembershipError::LocationIsNotEntityIdentity - | LocationMembershipError::LocationIsNotLanguageChannel - | LocationMembershipError::InvalidLocationPayload - | _ => AnalysisEngineError::InvalidEvidence, - } -} - #[cfg(test)] mod tests { use super::{ @@ -401,6 +386,16 @@ mod tests { value.refused_as_entity_identity_count = 0; value }, + { + let mut value = artifact.clone(); + value.refused_as_language_channel_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.location_count = u64::MAX; + value + }, { let mut value = artifact.clone(); value.inference_status.clear(); diff --git a/crates/analysis_engine/tests/location_membership_execution_contract.rs b/crates/analysis_engine/tests/location_membership_execution_contract.rs index 281de9e08..879385db4 100644 --- a/crates/analysis_engine/tests/location_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/location_membership_execution_contract.rs @@ -6,13 +6,21 @@ use analysis_engine::{ LocationMembershipDocument, execute_location_membership_run, }; use location_membership::LocationKind; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } +fn available(value: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(value).expect("availability") +} + +fn document(id: &str, kind: LocationKind) -> LocationMembershipDocument { + LocationMembershipDocument::new(id, kind, available("2026-07-31T23:59:59Z")).expect("document") +} + fn request() -> AnalysisRunRequest { AnalysisRunRequest { contract_version: 1, @@ -36,9 +44,9 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { fn mixed_documents() -> Vec { vec![ - LocationMembershipDocument::new("loc-a", LocationKind::Location).expect("location"), - LocationMembershipDocument::new("ent-b", LocationKind::EntityIdentity).expect("entity"), - LocationMembershipDocument::new("lang-c", LocationKind::LanguageChannel).expect("language"), + document("loc-a", LocationKind::Location), + document("ent-b", LocationKind::EntityIdentity), + document("lang-c", LocationKind::LanguageChannel), ] } @@ -89,6 +97,15 @@ fn mixed_location_kinds_emit_digest_bound_refusals_without_recovery_metric() { execution.terminal_result.result_schema_version.as_deref(), Some(LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION) ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .statistic_count, + 5 + ); } #[test] @@ -99,49 +116,85 @@ fn empty_single_kind_and_duplicate_identities_fail_closed() { Err(AnalysisEngineError::InvalidEvidence) ); let location_only = vec![ - LocationMembershipDocument::new("loc-a", LocationKind::Location).expect("location"), - LocationMembershipDocument::new("loc-b", LocationKind::Location).expect("location"), + document("loc-a", LocationKind::Location), + document("loc-b", LocationKind::Location), ]; assert_eq!( execute(&request, &location_only), Err(AnalysisEngineError::InvalidEvidence) ); let entity_only = vec![ - LocationMembershipDocument::new("ent-a", LocationKind::EntityIdentity).expect("entity"), - LocationMembershipDocument::new("ent-b", LocationKind::EntityIdentity).expect("entity"), + document("ent-a", LocationKind::EntityIdentity), + document("ent-b", LocationKind::EntityIdentity), ]; assert_eq!( execute(&request, &entity_only), Err(AnalysisEngineError::InvalidEvidence) ); let language_only = vec![ - LocationMembershipDocument::new("lang-a", LocationKind::LanguageChannel).expect("language"), - LocationMembershipDocument::new("lang-b", LocationKind::LanguageChannel).expect("language"), + document("lang-a", LocationKind::LanguageChannel), + document("lang-b", LocationKind::LanguageChannel), ]; assert_eq!( execute(&request, &language_only), Err(AnalysisEngineError::InvalidEvidence) ); let no_location = vec![ - LocationMembershipDocument::new("ent-a", LocationKind::EntityIdentity).expect("entity"), - LocationMembershipDocument::new("lang-b", LocationKind::LanguageChannel).expect("language"), + document("ent-a", LocationKind::EntityIdentity), + document("lang-b", LocationKind::LanguageChannel), ]; assert_eq!( execute(&request, &no_location), Err(AnalysisEngineError::InvalidEvidence) ); let duplicates = vec![ - LocationMembershipDocument::new("same", LocationKind::Location).expect("location"), - LocationMembershipDocument::new("same", LocationKind::EntityIdentity).expect("entity"), + document("same", LocationKind::Location), + document("same", LocationKind::EntityIdentity), ]; assert_eq!( execute(&request, &duplicates), Err(AnalysisEngineError::DuplicateEvidence) ); assert_eq!( - LocationMembershipDocument::new("", LocationKind::Location), + LocationMembershipDocument::new( + "", + LocationKind::Location, + available("2026-07-31T23:59:59Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn availability_cutoff_and_document_limit_fail_closed() { + let request = request(); + let mut documents = mixed_documents(); + documents[0] = LocationMembershipDocument::new( + "loc-a", + LocationKind::Location, + available("2026-08-01T00:00:00Z"), + ) + .expect("at cutoff"); + execute(&request, &documents).expect("availability at cutoff"); + + documents[0] = LocationMembershipDocument::new( + "loc-a", + LocationKind::Location, + available("2026-08-01T00:00:00.000000001Z"), + ) + .expect("after cutoff"); + assert_eq!( + execute(&request, &documents), Err(AnalysisEngineError::InvalidEvidence) ); + + let oversized = (0..=analysis_engine::MAX_EVIDENCE_UNITS) + .map(|index| document(&format!("document-{index}"), LocationKind::Location)) + .collect::>(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); } #[test] @@ -172,6 +225,19 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { ), Err(AnalysisEngineError::InvalidEvidence) ); + let mut mismatched_model = request.clone(); + mismatched_model.model_contract_version = "other-model".into(); + assert_eq!( + execute_location_membership_run( + &mismatched_model, + &accepted(&mismatched_model), + "snapshot-location-membership", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); for profile in [ "trsl_topic_lineage_v1", "fitted_candidate_k_v1", @@ -204,4 +270,15 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { Err(AnalysisEngineError::InvalidEvidence) ); } + assert!( + execute_location_membership_run( + &request, + &accepted(&request), + "snapshot-location-membership", + cutoff(), + &documents, + "not-a-time", + ) + .is_err() + ); } diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 482eabfed..2d9bcb004 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,7 +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 | -| location-membership analysis-run profile | ADR 0003/0022/0066; LocationKind Location/EntityIdentity/LanguageChannel | `analysis_engine` `location_membership_v1` binds `refuse_location_as_entity_identity` and `refuse_location_as_language_channel`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not membership-posterior ICC, not GPU, not MCMC, not topic birth/split/merge; not implemented-main | active-PR | +| location-membership analysis-run profile | ADR 0003/0022/0066; LocationKind Location/EntityIdentity/LanguageChannel | `analysis_engine` `location_membership_v1` binds explicit availability and bounded input to `refuse_location_as_entity_identity` and `refuse_location_as_language_channel`; digest-bound five-statistic refusals, not `identity_recovery_rate` inspect metric, 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/0066-location-membership-analysis-run.md b/docs/adr/0066-location-membership-analysis-run.md index e721af1a2..7e35ae4f6 100644 --- a/docs/adr/0066-location-membership-analysis-run.md +++ b/docs/adr/0066-location-membership-analysis-run.md @@ -31,16 +31,19 @@ Add the `location_membership_v1` analysis-run output profile to `analysis_engine`. The executor: - consumes already-validated `LocationMembershipDocument` rows with - closed `LocationKind` values; + closed `LocationKind` values and explicit availability clocks; - requires the request snapshot and knowledge cutoff to match the offered input construction; +- rejects post-cutoff documents and inputs above the shared 100,000-document + in-memory execution bound before aggregation; - invokes `refuse_location_as_entity_identity` and `refuse_location_as_language_channel` without reimplementing the location/entity/language vocabulary; - requires at least one location membership and at least one non-location treatment so the census is mixed; - emits a canonical SHA-256-digested `tepp.location_membership.v1` - artifact with per-kind counts, matching refusal counts, and inference + artifact with five reported census statistics (three per-kind counts and + two matching refusal counts), and inference status `location_is_not_entity_identity_not_language_channel`; - does not emit `identity_recovery_rate`, invent MCMC, select GPU backends, or emit topic birth/split/merge events. @@ -65,14 +68,15 @@ Operators can request cutoff-safe location-membership refusals as a digest-bound terminal result. The artifact does not claim MCMC, GPU parity, membership-posterior ICC, copied-text, citation-edge, corpus-background, method-effect estimation, or topic birth/split/merge. -Snapshot/profile/cutoff mismatch, empty or single-kind corpora, and -duplicate document identities fail closed. +Snapshot/profile/cutoff mismatch, future evidence, oversized input, empty or +single-kind corpora, and duplicate document identities fail closed. ## Verification The PR includes Rust unit and integration tests for mixed location/entity/language corpora, empty/single-kind/duplicate refusal, -snapshot / profile / cutoff mismatch, and artifact tampering. Run: +availability immediately at/after cutoff, oversized input, snapshot / profile / +cutoff mismatch, and artifact tampering. Run: ```text cargo fmt --all -- --check diff --git a/docs/doctoring/location-membership-analysis-run.md b/docs/doctoring/location-membership-analysis-run.md index 505e77ca4..099d2a66b 100644 --- a/docs/doctoring/location-membership-analysis-run.md +++ b/docs/doctoring/location-membership-analysis-run.md @@ -6,7 +6,10 @@ `location_membership` already refuses to treat location membership as permanent entity identity or as a language channel. This slice binds those refusals to a cutoff-safe analysis-run profile so operators can -request a digest-bound identity artifact. +request a digest-bound location-membership refusal artifact. Every input +document carries a validated availability clock; post-cutoff evidence and +corpora above the shared 100,000-document execution bound fail closed before +counting. The terminal summary reports the artifact's five census statistics. The artifact inference status is `location_is_not_entity_identity_not_language_channel`. From c0fbaabd8c95e69407c3b9e50f8d1846bd949598 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 03:14:08 +0000 Subject: [PATCH 05/75] 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 e6dc87ea14b443e5a4b457cb0c1e0dae6e120b9c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Tue, 1 Sep 2026 13:32:31 +0000 Subject: [PATCH 06/75] feat(analysis): bind episode-membership refusals to an analysis-run profile GAP-004 leftover / ADR 0072. Bind existing EventWindow and refuse_membership_outside_episode to cutoff-safe episode_membership_v1 (tepp.episode_membership.v1). Mixed contained/escaped census, AvailableTime cutoff exclusion, and MAX_EVIDENCE_UNITS fail closed. identity_recovery_rate stays library-side. Distinct from relation-absence, outcome-order, membership-target, location-membership, and copy-identity. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 1 + .../src/episode_membership_artifact.rs | 410 ++++++++++++++++++ crates/analysis_engine/src/lib.rs | 15 + .../episode_membership_execution_contract.rs | 252 +++++++++++ docs/TRACEABILITY.md | 1 + .../0072-episode-membership-analysis-run.md | 97 +++++ docs/adr/README.md | 2 + .../episode-membership-analysis-run.md | 18 + 11 files changed, 800 insertions(+) create mode 100644 crates/analysis_engine/src/episode_membership_artifact.rs create mode 100644 crates/analysis_engine/tests/episode_membership_execution_contract.rs create mode 100644 docs/adr/0072-episode-membership-analysis-run.md create mode 100644 docs/doctoring/episode-membership-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..2d35931ad 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] +- **Episode-membership analysis-run profile**: `analysis_engine` binds existing `episode_membership::EventWindow` and `refuse_membership_outside_episode` to cutoff-safe `episode_membership_v1` (`tepp.episode_membership.v1`) with inference status `membership_window_cannot_escape_episode_interval`. `identity_recovery_rate` stays library-side. Not relation-absence, not outcome-order, not membership-target, not location-membership, not membership-posterior ICC, not copied-text, not copy-identity, not citation-edge, not subevent containment, 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..71c6ff138 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,6 +72,7 @@ name = "analysis_engine" version = "0.2.0" dependencies = [ "corpus_split", + "episode_membership", "event_core", "membership_core", "relation_graph", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..d72a755f8 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) | +| Episode-membership analysis-run doctoring | [`docs/doctoring/episode-membership-analysis-run.md`](docs/doctoring/episode-membership-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..e5f500eba 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" } +episode_membership = { path = "../episode_membership", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs new file mode 100644 index 000000000..b727c9e22 --- /dev/null +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -0,0 +1,410 @@ +//! Digest-bound episode-membership refusals as an analysis-run profile. + +use episode_membership::{EpisodeMembershipError, EventWindow, refuse_membership_outside_episode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed episode-membership artifact. +pub const EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION: &str = "tepp.episode_membership.v1"; +/// Model contract required by the episode-membership execution path. +pub const EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION: &str = "episode_membership_v1"; +/// Analysis-run output profile required for an episode-membership artifact. +pub const EPISODE_MEMBERSHIP_OUTPUT_PROFILE: &str = "episode_membership_v1"; +/// Maximum canonical artifact JSON size. +pub const EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const EPISODE_MEMBERSHIP_INFERENCE_STATUS: &str = + "membership_window_cannot_escape_episode_interval"; + +/// One cutoff-admitted membership assignment against an episode window. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EpisodeMembershipAssignment { + assignment_id: String, + membership: EventWindow, + episode: EventWindow, + available_time: AvailableTime, +} + +impl EpisodeMembershipAssignment { + /// Construct a bounded episode-membership assignment. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the assignment + /// identity is empty or oversized. + pub fn new( + assignment_id: impl Into, + membership: EventWindow, + episode: EventWindow, + available_time: AvailableTime, + ) -> Result { + let assignment_id = assignment_id.into(); + if !valid_identifier(&assignment_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + assignment_id, + membership, + episode, + available_time, + }) + } + + /// Return the opaque assignment identity. + #[must_use] + pub fn assignment_id(&self) -> &str { + &self.assignment_id + } + + /// Return the membership event-time window. + #[must_use] + pub const fn membership(&self) -> EventWindow { + self.membership + } + + /// Return the episode event-time window. + #[must_use] + pub const fn episode(&self) -> EventWindow { + self.episode + } + + /// Return the availability time used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded episode-membership census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EpisodeMembershipArtifact { + /// 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 assignments. + pub knowledge_cutoff: String, + /// Number of assignments admitted at the cutoff. + pub assignment_count: u64, + /// Assignments contained in their episode window. + pub contained_count: u64, + /// Assignments that escaped their episode window. + pub escaped_count: u64, + /// Escaped assignments refused as membership-outside-episode. + pub refused_as_escape_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl EpisodeMembershipArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEpisodeMembershipArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidEpisodeMembershipArtifact)?; + 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() > EPISODE_MEMBERSHIP_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 status_sum = self.contained_count.checked_add(self.escaped_count); + if self.schema_version != EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.assignment_count < 2 + || self.contained_count == 0 + || self.escaped_count == 0 + || status_sum != Some(self.assignment_count) + || self.refused_as_escape_count != self.escaped_count + || self.inference_status != EPISODE_MEMBERSHIP_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidEpisodeMembershipArtifact); + } + Ok(()) + } +} + +/// One completed episode-membership artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct EpisodeMembershipExecution { + /// Digest-bound completed episode-membership census. + pub artifact: EpisodeMembershipArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe episode-membership refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_membership_outside_episode`] already on +/// protected main. Contained windows stay membership. Escaped windows stay +/// refusals, never subevent-versus-parent containment. 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, inverted or escaped membership treated as success, +/// duplicate assignment identity, oversized corpus, or invalid artifact +/// error. +pub fn execute_episode_membership_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + assignments: &[EpisodeMembershipAssignment], + 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 != EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION + || request.output_profile != EPISODE_MEMBERSHIP_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if assignments.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let (contained_count, escaped_count, refused_as_escape_count) = + census_assignments(assignments, knowledge_cutoff)?; + let assignment_count = contained_count + .checked_add(escaped_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + if assignment_count < 2 + || contained_count == 0 + || escaped_count == 0 + || refused_as_escape_count != escaped_count + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = EpisodeMembershipArtifact { + schema_version: EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + assignment_count, + contained_count, + escaped_count, + refused_as_escape_count, + inference_status: EPISODE_MEMBERSHIP_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "episode_membership", + assignment_count, + 4, + EPISODE_MEMBERSHIP_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("episode_membership_artifact_{}", &digest[..16]), + digest, + EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(EpisodeMembershipExecution { + artifact, + terminal_result, + }) +} + +fn census_assignments( + assignments: &[EpisodeMembershipAssignment], + knowledge_cutoff: KnowledgeCutoff, +) -> Result<(u64, u64, u64), AnalysisEngineError> { + let mut seen = std::collections::BTreeSet::new(); + let mut contained_count = 0_u64; + let mut escaped_count = 0_u64; + let mut refused_as_escape_count = 0_u64; + for assignment in assignments { + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + if assignment.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + match refuse_membership_outside_episode(assignment.membership(), assignment.episode()) { + Ok(()) => { + contained_count = increment(contained_count)?; + } + Err(EpisodeMembershipError::MembershipEscapesEpisode) => { + refused_as_escape_count = increment(refused_as_escape_count)?; + escaped_count = increment(escaped_count)?; + } + Err( + EpisodeMembershipError::InvertedEventWindow + | EpisodeMembershipError::InvalidEpisodePayload + | _, + ) => return Err(AnalysisEngineError::InvalidEvidence), + } + } + Ok((contained_count, escaped_count, refused_as_escape_count)) +} + +fn increment(count: u64) -> Result { + count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + +#[cfg(test)] +mod tests { + use super::{ + EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + EPISODE_MEMBERSHIP_INFERENCE_STATUS, EpisodeMembershipArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> EpisodeMembershipArtifact { + EpisodeMembershipArtifact { + schema_version: EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count: 2, + contained_count: 1, + escaped_count: 1, + refused_as_escape_count: 1, + inference_status: EPISODE_MEMBERSHIP_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &EpisodeMembershipArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidEpisodeMembershipArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + EpisodeMembershipArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + EpisodeMembershipArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidEpisodeMembershipArtifact) + ); + assert_eq!( + EpisodeMembershipArtifact::from_json( + &"x".repeat(EPISODE_MEMBERSHIP_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.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.contained_count = 0; + value.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.escaped_count = 0; + value.refused_as_escape_count = 0; + value.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_escape_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/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..19a9942d8 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -11,6 +11,7 @@ //! contracts and preserves their artifact meaning. mod case_deletion_refit; +mod episode_membership_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; @@ -41,6 +42,13 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Episode-membership artifact and execution contracts from this engine. +pub use episode_membership_artifact::{ + EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION, EPISODE_MEMBERSHIP_OUTPUT_PROFILE, + EpisodeMembershipArtifact, EpisodeMembershipAssignment, EpisodeMembershipExecution, + execute_episode_membership_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +256,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// An episode-membership artifact violated its bounded schema or count invariants. + InvalidEpisodeMembershipArtifact, } 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::InvalidEpisodeMembershipArtifact => "invalid episode-membership artifact", }; formatter.write_str(message) } @@ -681,6 +692,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidEpisodeMembershipArtifact, + "invalid episode-membership artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs new file mode 100644 index 000000000..093df7c29 --- /dev/null +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -0,0 +1,252 @@ +//! End-to-end contract for cutoff-safe episode-membership refusals. + +use analysis_engine::{ + AnalysisEngineError, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION, EPISODE_MEMBERSHIP_OUTPUT_PROFILE, + EpisodeMembershipAssignment, MAX_EVIDENCE_UNITS, execute_episode_membership_run, +}; +use episode_membership::EventWindow; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn window(start: i64, end: i64) -> EventWindow { + EventWindow::new(start, end).expect("window") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "episode-membership-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-episode-membership".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION.into(), + output_profile: EPISODE_MEMBERSHIP_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-episode-membership", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn assignment( + assignment_id: &str, + membership: EventWindow, + episode: EventWindow, + stamp: &str, +) -> EpisodeMembershipAssignment { + EpisodeMembershipAssignment::new(assignment_id, membership, episode, available(stamp)) + .expect("assignment") +} + +fn mixed_assignments() -> Vec { + let episode = window(10, 20); + vec![ + assignment( + "contained-a", + window(11, 19), + episode, + "2026-07-01T00:00:00Z", + ), + assignment("escaped-b", window(9, 15), episode, "2026-07-02T00:00:00Z"), + ] +} + +fn execute( + request: &AnalysisRunRequest, + assignments: &[EpisodeMembershipAssignment], +) -> Result { + execute_episode_membership_run( + request, + &accepted(request), + "snapshot-episode-membership", + cutoff(), + assignments, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_windows_emit_digest_bound_refusals_without_recovery_metric() { + let request = request(); + let execution = execute(&request, &mixed_assignments()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.contained_count, 1); + assert_eq!(execution.artifact.escaped_count, 1); + assert_eq!(execution.artifact.refused_as_escape_count, 1); + assert_eq!( + execution.artifact.inference_status, + "membership_window_cannot_escape_episode_interval" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + assert!(!payload.contains("subevent")); + 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(EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn future_available_assignments_are_excluded() { + let request = request(); + let episode = window(10, 20); + let mut with_future = mixed_assignments(); + with_future.push(assignment( + "future-c", + window(11, 12), + episode, + "2026-08-02T00:00:00Z", + )); + let execution = execute(&request, &with_future).expect("cutoff"); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.contained_count, 1); +} + +#[test] +fn empty_or_single_class_and_duplicate_fail_closed() { + let request = request(); + let episode = window(10, 20); + let stamp = "2026-07-01T00:00:00Z"; + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let contained_only = vec![ + assignment("contained-a", window(11, 12), episode, stamp), + assignment("contained-b", window(13, 14), episode, stamp), + ]; + assert_eq!( + execute(&request, &contained_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let escaped_only = vec![ + assignment("escaped-a", window(1, 5), episode, stamp), + assignment("escaped-b", window(21, 25), episode, stamp), + ]; + assert_eq!( + execute(&request, &escaped_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + assignment("same", window(11, 12), episode, stamp), + assignment("same", window(1, 5), episode, stamp), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + EpisodeMembershipAssignment::new("", window(11, 12), episode, available(stamp)), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { + let request = request(); + let assignments = mixed_assignments(); + assert_eq!( + execute_episode_membership_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &assignments, + "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_episode_membership_run( + &mismatched, + &accepted(&mismatched), + "snapshot-episode-membership", + cutoff(), + &assignments, + "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", + "membership_target_v1", + "outcome_order_v1", + "relation_absence_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_episode_membership_run( + &reused, + &accepted(&reused), + "snapshot-episode-membership", + cutoff(), + &assignments, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + let episode = window(10, 20); + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + assignment( + &format!("assignment-{index}"), + window(11, 12), + episode, + "2026-07-01T00:00:00Z", + ) + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..f96be4f53 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 | +| episode-membership analysis-run profile | ADR 0003/0022/0072; membership windows cannot escape the episode interval | `analysis_engine` `episode_membership_v1` binds `refuse_membership_outside_episode`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not relation-absence, not outcome-order, not membership-target, not location-membership, not subevent containment, 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/0072-episode-membership-analysis-run.md b/docs/adr/0072-episode-membership-analysis-run.md new file mode 100644 index 000000000..398d231e8 --- /dev/null +++ b/docs/adr/0072-episode-membership-analysis-run.md @@ -0,0 +1,97 @@ +# ADR 0072 — Episode-membership 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 (episode membership cannot escape the episode event-time interval) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0071 (relation-absence), ADR 0070 (outcome-order), ADR 0069 (membership-target), ADR 0068 (topic-context posterior), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0064 (provenance-is-not-transition / citation-edge), or ADR 0058 (copy-identity / template-copy). This is membership-window containment, not subevent-versus-parent containment. +**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 a membership window that starts before or +ends after its episode, via `episode_membership::EventWindow` and +`refuse_membership_outside_episode`. Operators still cannot request that +census as a digest-bound analysis-run output. + +Relation-absence (#460 / ADR 0071) binds observation status. +Outcome-order (#458 / ADR 0070) binds IPO event-time order. +Membership-target (#434 / ADR 0069) binds `MembershipTargetKind`. +Location-membership (#430 / ADR 0066) binds geographic/market assignment. + +`identity_recovery_rate` stays library-side. This slice does not put a +`scientific_acceptance` metric on inspect payloads. Subevent parent-window +containment remains `subevent_containment`. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `episode_membership_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `EpisodeMembershipAssignment` rows with + closed `EventWindow` membership/episode bounds and availability time; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- excludes assignments whose availability is later than the knowledge + cutoff; +- invokes `refuse_membership_outside_episode` without reimplementing the + containment vocabulary; +- requires a mixed census of at least one contained and one escaped + assignment after cutoff exclusion; +- emits a canonical SHA-256-digested `tepp.episode_membership.v1` artifact + with contained/escaped counts, matching escape-refusal counts, and + inference status `membership_window_cannot_escape_episode_interval`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate relation-absence (#460 / ADR 0071) — rejected because that + profile binds observation status, not episode-window containment. +2. Duplicate outcome-order (#458 / ADR 0070) — rejected because that + profile binds IPO event-time order. +3. Duplicate membership-target (#434 / ADR 0069) — rejected because that + profile binds `MembershipTargetKind`. +4. Duplicate location-membership (#430 / ADR 0066) — rejected because + that profile binds `location_membership` LocationKind refusals. +5. Put `identity_recovery_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and + `tepp.scientific_acceptance.v1` never appears. +6. Bind the existing episode-membership refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe episode-membership refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, relation-absence, outcome-order, membership-target, +location-membership, membership-posterior ICC, copied-text, +copy-identity, citation-edge, subevent containment, method-effect +estimation, or topic birth/split/merge. Snapshot / profile / cutoff +mismatch, empty or single-class corpora, duplicate assignment +identities, and oversized corpora fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed +contained/escaped corpora, cutoff exclusion, empty/single-class/duplicate +refusal, snapshot / profile / cutoff mismatch, oversize, 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 `episode_membership_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps membership +windows inside the episode interval, keeps this distinct from +subevent-versus-parent containment, and keeps `identity_recovery_rate` +off inspect payloads. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..15005b7bd 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. | +| [0072](0072-episode-membership-analysis-run.md) | Episode-membership refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `EventWindow` + `refuse_membership_outside_episode`, not relation-absence, not outcome-order, not membership-target. | | [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. +- **episode-membership analysis-run profile:** ADR 0072. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/episode-membership-analysis-run.md b/docs/doctoring/episode-membership-analysis-run.md new file mode 100644 index 000000000..b6b50888a --- /dev/null +++ b/docs/doctoring/episode-membership-analysis-run.md @@ -0,0 +1,18 @@ +# Episode-membership analysis-run composition + +**Active slice:** ADR 0072 / `episode_membership_v1` +**Protected-main status:** not implemented-main + +`episode_membership` already refuses a membership window that starts +before or ends after its episode. This slice binds `EventWindow` and +`refuse_membership_outside_episode` to a cutoff-safe analysis-run +profile so operators can request a digest-bound identity artifact. + +The artifact inference status is +`membership_window_cannot_escape_episode_interval`. +`identity_recovery_rate` stays library-side. This is membership-window +containment, not subevent-versus-parent containment. This is not +relation-absence, not outcome-order, not membership-target, not +location-membership, not membership-posterior ICC, not copied-text, not +copy-identity, not citation-edge, not GPU, not MCMC, and not topic +birth/split/merge. From 3c66702d9d7bd65d8af8aeb9bc437b7174f13666 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:16:49 +0000 Subject: [PATCH 07/75] feat(analysis): bind inferred-status refusals to an analysis-run profile GAP-004 leftover / ADR 0073. Bind existing EvidenceStatus, refuse_inferred_as_observed, and refuse_inferred_as_transition to cutoff-safe inferred_status_v1. Mixed observed/inferred corpora emit a digest-bound census. identity_recovery_rate stays library-side. Fixes #472. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 1 + .../src/inferred_status_artifact.rs | 436 ++++++++++++++++++ crates/analysis_engine/src/lib.rs | 14 + .../inferred_status_execution_contract.rs | 238 ++++++++++ docs/TRACEABILITY.md | 1 + docs/adr/0073-inferred-status-analysis-run.md | 101 ++++ docs/adr/README.md | 2 + .../doctoring/inferred-status-analysis-run.md | 19 + 11 files changed, 816 insertions(+) create mode 100644 crates/analysis_engine/src/inferred_status_artifact.rs create mode 100644 crates/analysis_engine/tests/inferred_status_execution_contract.rs create mode 100644 docs/adr/0073-inferred-status-analysis-run.md create mode 100644 docs/doctoring/inferred-status-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..e4a08dd60 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] +- **Inferred-status analysis-run profile**: `analysis_engine` binds existing `inferred_status::EvidenceStatus`, `refuse_inferred_as_observed`, and `refuse_inferred_as_transition` to cutoff-safe `inferred_status_v1` (`tepp.inferred_status.v1`) with inference status `inferred_is_not_observed_and_not_transition`. `identity_recovery_rate` stays library-side. Not relation-absence, not episode-membership, not outcome-order, not membership-target, not location-membership, not membership-posterior ICC, not copied-text, not copy-identity, not citation-edge, not subevent containment, 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..6cbef05d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,7 @@ version = "0.2.0" dependencies = [ "corpus_split", "event_core", + "inferred_status", "membership_core", "relation_graph", "serde", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..779335567 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) | +| Inferred-status analysis-run doctoring | [`docs/doctoring/inferred-status-analysis-run.md`](docs/doctoring/inferred-status-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..b6ed3bba3 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" } +inferred_status = { path = "../inferred_status", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/inferred_status_artifact.rs b/crates/analysis_engine/src/inferred_status_artifact.rs new file mode 100644 index 000000000..71bd34e49 --- /dev/null +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -0,0 +1,436 @@ +//! Digest-bound inferred-status refusals as an analysis-run profile. + +use inferred_status::{ + EvidenceStatus, InferredStatusError, refuse_inferred_as_observed, refuse_inferred_as_transition, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed inferred-status artifact. +pub const INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION: &str = "tepp.inferred_status.v1"; +/// Model contract required by the inferred-status execution path. +pub const INFERRED_STATUS_MODEL_CONTRACT_VERSION: &str = "inferred_status_v1"; +/// Analysis-run output profile required for an inferred-status artifact. +pub const INFERRED_STATUS_OUTPUT_PROFILE: &str = "inferred_status_v1"; +/// Maximum canonical artifact JSON size. +pub const INFERRED_STATUS_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const INFERRED_STATUS_INFERENCE_STATUS: &str = "inferred_is_not_observed_and_not_transition"; + +/// One cutoff-admitted evidence row with closed observed/inferred status. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InferredStatusEvidence { + evidence_id: String, + status: EvidenceStatus, + available_time: AvailableTime, +} + +impl InferredStatusEvidence { + /// Construct a bounded inferred-status evidence row. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the evidence + /// identity is empty or oversized. + pub fn new( + evidence_id: impl Into, + status: EvidenceStatus, + available_time: AvailableTime, + ) -> Result { + let evidence_id = evidence_id.into(); + if !valid_identifier(&evidence_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + evidence_id, + status, + available_time, + }) + } + + /// Return the opaque evidence identity. + #[must_use] + pub fn evidence_id(&self) -> &str { + &self.evidence_id + } + + /// Return the closed observed/inferred status. + #[must_use] + pub const fn status(&self) -> EvidenceStatus { + self.status + } + + /// Return the availability time used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded inferred-status census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InferredStatusArtifact { + /// 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 rows. + pub knowledge_cutoff: String, + /// Number of evidence rows admitted at the cutoff. + pub evidence_count: u64, + /// Directly observed rows admitted at the cutoff. + pub observed_count: u64, + /// Inferred rows admitted at the cutoff. + pub inferred_count: u64, + /// Inferred rows refused as observed evidence. + pub refused_as_observed_count: u64, + /// Inferred rows refused as state transitions. + pub refused_as_transition_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl InferredStatusArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidInferredStatusArtifact`] when the + /// schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > INFERRED_STATUS_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidInferredStatusArtifact)?; + 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() > INFERRED_STATUS_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 status_sum = self.observed_count.checked_add(self.inferred_count); + if self.schema_version != INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.evidence_count < 2 + || self.observed_count == 0 + || self.inferred_count == 0 + || status_sum != Some(self.evidence_count) + || self.refused_as_observed_count != self.inferred_count + || self.refused_as_transition_count != self.inferred_count + || self.inference_status != INFERRED_STATUS_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidInferredStatusArtifact); + } + Ok(()) + } +} + +/// One completed inferred-status artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct InferredStatusExecution { + /// Digest-bound completed inferred-status census. + pub artifact: InferredStatusArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe inferred-status refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_inferred_as_observed`] and +/// [`refuse_inferred_as_transition`] already on protected main. Observed +/// statuses stay observed. Inferred statuses stay refusals, never observed +/// evidence and never transitions. 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, inferred treated as observed or transition, +/// duplicate evidence identity, oversized corpus, or invalid artifact +/// error. +pub fn execute_inferred_status_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + evidence: &[InferredStatusEvidence], + 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 != INFERRED_STATUS_MODEL_CONTRACT_VERSION + || request.output_profile != INFERRED_STATUS_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if evidence.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let (observed_count, inferred_count, refused_as_observed_count, refused_as_transition_count) = + census_evidence(evidence, knowledge_cutoff)?; + let evidence_count = observed_count + .checked_add(inferred_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + if evidence_count < 2 + || observed_count == 0 + || inferred_count == 0 + || refused_as_observed_count != inferred_count + || refused_as_transition_count != inferred_count + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = InferredStatusArtifact { + schema_version: INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + evidence_count, + observed_count, + inferred_count, + refused_as_observed_count, + refused_as_transition_count, + inference_status: INFERRED_STATUS_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "inferred_status", + evidence_count, + 4, + INFERRED_STATUS_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("inferred_status_artifact_{}", &digest[..16]), + digest, + INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(InferredStatusExecution { + artifact, + terminal_result, + }) +} + +fn census_evidence( + evidence: &[InferredStatusEvidence], + knowledge_cutoff: KnowledgeCutoff, +) -> Result<(u64, u64, u64, u64), AnalysisEngineError> { + let mut seen = std::collections::BTreeSet::new(); + let mut observed_count = 0_u64; + let mut inferred_count = 0_u64; + let mut refused_as_observed_count = 0_u64; + let mut refused_as_transition_count = 0_u64; + for row in evidence { + if !seen.insert(row.evidence_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + if row.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + match row.status() { + EvidenceStatus::Observed => { + refuse_inferred_as_observed(row.status()).map_err(map_inferred_status_error)?; + refuse_inferred_as_transition(row.status()).map_err(map_inferred_status_error)?; + observed_count = increment(observed_count)?; + } + EvidenceStatus::Inferred => { + match refuse_inferred_as_observed(row.status()) { + Err(InferredStatusError::InferredIsNotObserved) => { + refused_as_observed_count = increment(refused_as_observed_count)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match refuse_inferred_as_transition(row.status()) { + Err(InferredStatusError::InferredIsNotTransition) => { + refused_as_transition_count = increment(refused_as_transition_count)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + inferred_count = increment(inferred_count)?; + } + } + } + Ok(( + observed_count, + inferred_count, + refused_as_observed_count, + refused_as_transition_count, + )) +} + +fn increment(count: u64) -> Result { + count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + +fn map_inferred_status_error(error: InferredStatusError) -> AnalysisEngineError { + match error { + InferredStatusError::InferredIsNotObserved + | InferredStatusError::InferredIsNotTransition + | InferredStatusError::InvalidStatusPayload + | _ => AnalysisEngineError::InvalidEvidence, + } +} + +#[cfg(test)] +mod tests { + use super::{ + INFERRED_STATUS_ARTIFACT_BYTE_LIMIT, INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, + INFERRED_STATUS_INFERENCE_STATUS, InferredStatusArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> InferredStatusArtifact { + InferredStatusArtifact { + schema_version: INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + evidence_count: 2, + observed_count: 1, + inferred_count: 1, + refused_as_observed_count: 1, + refused_as_transition_count: 1, + inference_status: INFERRED_STATUS_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &InferredStatusArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidInferredStatusArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + InferredStatusArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + InferredStatusArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidInferredStatusArtifact) + ); + assert_eq!( + InferredStatusArtifact::from_json(&"x".repeat(INFERRED_STATUS_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.evidence_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.observed_count = 0; + value.evidence_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.inferred_count = 0; + value.refused_as_observed_count = 0; + value.refused_as_transition_count = 0; + value.evidence_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_observed_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_transition_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/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..68a2c2434 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -11,6 +11,7 @@ //! contracts and preserves their artifact meaning. mod case_deletion_refit; +mod inferred_status_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; @@ -41,6 +42,12 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Inferred-status artifact and execution contracts from this engine. +pub use inferred_status_artifact::{ + INFERRED_STATUS_ARTIFACT_BYTE_LIMIT, INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, + INFERRED_STATUS_MODEL_CONTRACT_VERSION, INFERRED_STATUS_OUTPUT_PROFILE, InferredStatusArtifact, + InferredStatusEvidence, InferredStatusExecution, execute_inferred_status_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +255,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// An inferred-status artifact violated its bounded schema or count invariants. + InvalidInferredStatusArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +271,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::InvalidInferredStatusArtifact => "invalid inferred-status artifact", }; formatter.write_str(message) } @@ -681,6 +691,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidInferredStatusArtifact, + "invalid inferred-status artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/tests/inferred_status_execution_contract.rs b/crates/analysis_engine/tests/inferred_status_execution_contract.rs new file mode 100644 index 000000000..548c72e13 --- /dev/null +++ b/crates/analysis_engine/tests/inferred_status_execution_contract.rs @@ -0,0 +1,238 @@ +//! End-to-end contract for cutoff-safe inferred-status refusals. + +use analysis_engine::{ + AnalysisEngineError, INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, + INFERRED_STATUS_MODEL_CONTRACT_VERSION, INFERRED_STATUS_OUTPUT_PROFILE, InferredStatusEvidence, + MAX_EVIDENCE_UNITS, execute_inferred_status_run, +}; +use inferred_status::EvidenceStatus; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "inferred-status-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-inferred-status".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: INFERRED_STATUS_MODEL_CONTRACT_VERSION.into(), + output_profile: INFERRED_STATUS_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-inferred-status", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn evidence(evidence_id: &str, status: EvidenceStatus, stamp: &str) -> InferredStatusEvidence { + InferredStatusEvidence::new(evidence_id, status, available(stamp)).expect("evidence") +} + +fn mixed_evidence() -> Vec { + vec![ + evidence( + "observed-a", + EvidenceStatus::Observed, + "2026-07-01T00:00:00Z", + ), + evidence( + "inferred-b", + EvidenceStatus::Inferred, + "2026-07-02T00:00:00Z", + ), + ] +} + +fn execute( + request: &AnalysisRunRequest, + evidence: &[InferredStatusEvidence], +) -> Result { + execute_inferred_status_run( + request, + &accepted(request), + "snapshot-inferred-status", + cutoff(), + evidence, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_statuses_emit_digest_bound_refusals_without_recovery_metric() { + let request = request(); + let execution = execute(&request, &mixed_evidence()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.evidence_count, 2); + assert_eq!(execution.artifact.observed_count, 1); + assert_eq!(execution.artifact.inferred_count, 1); + assert_eq!(execution.artifact.refused_as_observed_count, 1); + assert_eq!(execution.artifact.refused_as_transition_count, 1); + assert_eq!( + execution.artifact.inference_status, + "inferred_is_not_observed_and_not_transition" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + assert!(!payload.contains("unobserved")); + assert!(!payload.contains("no_relationship")); + 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(INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn future_available_evidence_is_excluded() { + let request = request(); + let mut with_future = mixed_evidence(); + with_future.push(evidence( + "future-c", + EvidenceStatus::Observed, + "2026-08-02T00:00:00Z", + )); + let execution = execute(&request, &with_future).expect("cutoff"); + assert_eq!(execution.artifact.evidence_count, 2); + assert_eq!(execution.artifact.observed_count, 1); +} + +#[test] +fn empty_or_single_class_and_duplicate_fail_closed() { + let request = request(); + let stamp = "2026-07-01T00:00:00Z"; + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let observed_only = vec![ + evidence("observed-a", EvidenceStatus::Observed, stamp), + evidence("observed-b", EvidenceStatus::Observed, stamp), + ]; + assert_eq!( + execute(&request, &observed_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let inferred_only = vec![ + evidence("inferred-a", EvidenceStatus::Inferred, stamp), + evidence("inferred-b", EvidenceStatus::Inferred, stamp), + ]; + assert_eq!( + execute(&request, &inferred_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + evidence("same", EvidenceStatus::Observed, stamp), + evidence("same", EvidenceStatus::Inferred, stamp), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + InferredStatusEvidence::new("", EvidenceStatus::Observed, available(stamp)), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { + let request = request(); + let rows = mixed_evidence(); + assert_eq!( + execute_inferred_status_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &rows, + "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_inferred_status_run( + &mismatched, + &accepted(&mismatched), + "snapshot-inferred-status", + cutoff(), + &rows, + "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", + "membership_target_v1", + "outcome_order_v1", + "relation_absence_v1", + "episode_membership_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_inferred_status_run( + &reused, + &accepted(&reused), + "snapshot-inferred-status", + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + evidence( + &format!("evidence-{index}"), + EvidenceStatus::Observed, + "2026-07-01T00:00:00Z", + ) + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..14aca9d92 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 | +| inferred-status analysis-run profile | ADR 0003/0022/0073; inferred relations cannot be observed evidence or transitions | `analysis_engine` `inferred_status_v1` binds `refuse_inferred_as_observed` and `refuse_inferred_as_transition`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not relation-absence, not episode-membership, not outcome-order, not membership-target, not location-membership, not subevent containment, 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/0073-inferred-status-analysis-run.md b/docs/adr/0073-inferred-status-analysis-run.md new file mode 100644 index 000000000..45a1a082b --- /dev/null +++ b/docs/adr/0073-inferred-status-analysis-run.md @@ -0,0 +1,101 @@ +# ADR 0073 — Inferred-status 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 (inferred relations cannot be promoted to observed evidence or transitions) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0072 (episode-membership), ADR 0071 (relation-absence), ADR 0070 (outcome-order), ADR 0069 (membership-target), ADR 0068 (topic-context posterior), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0064 (provenance-is-not-transition / citation-edge), or ADR 0058 (copy-identity / template-copy). This is observed-versus-inferred promotion refusal, not unobserved-is-not-negative and not membership-window containment. +**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 treat an inferred relation as observed +evidence or as a state transition, via `inferred_status::EvidenceStatus`, +`refuse_inferred_as_observed`, and `refuse_inferred_as_transition`. +Operators still cannot request that census as a digest-bound analysis-run +output. + +Episode-membership (#461 / ADR 0072) binds membership-window containment. +Relation-absence (#460 / ADR 0071) binds `unobserved` as not-negative +with a three-status vocabulary that already treats `inferred` as +presence. Outcome-order (#458 / ADR 0070) binds IPO event-time order. +Membership-target (#434 / ADR 0069) binds `MembershipTargetKind`. +Location-membership (#430 / ADR 0066) binds geographic/market assignment. + +`identity_recovery_rate` stays library-side. This slice does not put a +`scientific_acceptance` metric on inspect payloads. `unobserved` and +`no_relationship` are not wire statuses here. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `inferred_status_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `InferredStatusEvidence` rows with closed + `EvidenceStatus` values and availability time; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- excludes rows whose availability is later than the knowledge cutoff; +- invokes `refuse_inferred_as_observed` and + `refuse_inferred_as_transition` without reimplementing the + `observed` / `inferred` vocabulary; +- requires a mixed census of at least one `observed` and one `inferred` + after cutoff exclusion; +- emits a canonical SHA-256-digested `tepp.inferred_status.v1` artifact + with per-status counts, matching inferred refusal counts, and + inference status `inferred_is_not_observed_and_not_transition`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate episode-membership (#461 / ADR 0072) — rejected because that + profile binds membership-window containment, not inferred promotion. +2. Duplicate relation-absence (#460 / ADR 0071) — rejected because that + profile binds `unobserved` as not-negative and already treats + `inferred` as presence. +3. Duplicate outcome-order (#458 / ADR 0070) — rejected because that + profile binds IPO event-time order. +4. Duplicate membership-target (#434 / ADR 0069) — rejected because that + profile binds `MembershipTargetKind`. +5. Put `identity_recovery_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and + `tepp.scientific_acceptance.v1` never appears. +6. Bind the existing inferred-status refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe inferred-status refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, relation-absence, episode-membership, outcome-order, +membership-target, location-membership, membership-posterior ICC, +copied-text, copy-identity, citation-edge, subevent containment, +method-effect estimation, or topic birth/split/merge. Snapshot / +profile / cutoff mismatch, empty or single-class corpora, duplicate +evidence identities, and oversized corpora fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed +`observed` / `inferred` corpora, cutoff exclusion, +empty/single-class/duplicate refusal, snapshot / profile / cutoff +mismatch, oversize, 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 `inferred_status_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps inferred +relations out of observed evidence and out of transitions, keeps this +distinct from relation-absence unobserved-is-not-negative, and keeps +`identity_recovery_rate` off inspect payloads. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..656a21928 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. | +| [0073](0073-inferred-status-analysis-run.md) | Inferred-status refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `EvidenceStatus` + `refuse_inferred_as_observed` / `refuse_inferred_as_transition`, not relation-absence, not episode-membership. | | [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. +- **inferred-status analysis-run profile:** ADR 0073. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/inferred-status-analysis-run.md b/docs/doctoring/inferred-status-analysis-run.md new file mode 100644 index 000000000..326b5713b --- /dev/null +++ b/docs/doctoring/inferred-status-analysis-run.md @@ -0,0 +1,19 @@ +# Inferred-status analysis-run composition + +**Active slice:** ADR 0073 / `inferred_status_v1` +**Protected-main status:** not implemented-main + +`inferred_status` already refuses to treat an inferred relation as +observed evidence or as a state transition. This slice binds +`EvidenceStatus`, `refuse_inferred_as_observed`, and +`refuse_inferred_as_transition` to a cutoff-safe analysis-run profile so +operators can request a digest-bound identity artifact. + +The artifact inference status is +`inferred_is_not_observed_and_not_transition`. +`identity_recovery_rate` stays library-side. `unobserved` and +`no_relationship` are not wire statuses here. This is not +relation-absence, not episode-membership, not outcome-order, not +membership-target, not location-membership, not membership-posterior +ICC, not copied-text, not copy-identity, not citation-edge, not +subevent containment, not GPU, not MCMC, and not topic birth/split/merge. From 9332f05f4d7d139536ae34020cfefd8965a6f3f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:33:29 +0900 Subject: [PATCH 08/75] fix(analysis): bound inferred-status artifact claims Use the review-reproduced oversized compact artifact as RED evidence. Enforce the same MAX_EVIDENCE_UNITS invariant at artifact deserialization/serialization that execution already enforces, and add regression coverage for both directions. --- .../src/inferred_status_artifact.rs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/inferred_status_artifact.rs b/crates/analysis_engine/src/inferred_status_artifact.rs index 71bd34e49..489391121 100644 --- a/crates/analysis_engine/src/inferred_status_artifact.rs +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -150,6 +150,7 @@ impl InferredStatusArtifact { || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() || self.evidence_count < 2 + || self.evidence_count > MAX_EVIDENCE_UNITS as u64 || self.observed_count == 0 || self.inferred_count == 0 || status_sum != Some(self.evidence_count) @@ -327,7 +328,7 @@ mod tests { INFERRED_STATUS_ARTIFACT_BYTE_LIMIT, INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, INFERRED_STATUS_INFERENCE_STATUS, InferredStatusArtifact, }; - use crate::AnalysisEngineError; + use crate::{AnalysisEngineError, MAX_EVIDENCE_UNITS}; fn artifact() -> InferredStatusArtifact { InferredStatusArtifact { @@ -370,6 +371,26 @@ mod tests { ); } + #[test] + fn artifact_claimed_count_above_execution_limit_fails_parse_and_serialize() { + let mut oversized = artifact(); + oversized.evidence_count = MAX_EVIDENCE_UNITS as u64 + 1; + oversized.observed_count = 1; + oversized.inferred_count = MAX_EVIDENCE_UNITS as u64; + oversized.refused_as_observed_count = MAX_EVIDENCE_UNITS as u64; + oversized.refused_as_transition_count = MAX_EVIDENCE_UNITS as u64; + + assert_eq!( + oversized.to_json(), + Err(AnalysisEngineError::InvalidInferredStatusArtifact) + ); + let unchecked_payload = serde_json::to_string(&oversized).expect("unchecked fixture json"); + assert_eq!( + InferredStatusArtifact::from_json(&unchecked_payload), + Err(AnalysisEngineError::InvalidInferredStatusArtifact) + ); + } + #[test] fn artifact_metadata_tampering_fails_closed() { let artifact = artifact(); From 9ae0488e44bc0775b2289c71d83d88c57e660b0d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:36:07 +0900 Subject: [PATCH 09/75] docs: make inferred-status verification code-current Document the artifact-count invariant and exact-head landing procedure, and collapse the duplicated documentation map into one canonical index so the new doctoring entry cannot diverge between copies. --- DOCUMENTATION.md | 86 ------------------- docs/adr/0073-inferred-status-analysis-run.md | 15 +++- 2 files changed, 13 insertions(+), 88 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 779335567..5ccbfee63 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -62,10 +62,6 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Relation absence is not negative evidence | [`docs/research/relation-absence-not-negative.md`](docs/research/relation-absence-not-negative.md) | | Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | -| Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | -| Span-grounded mention extent doctoring | [`docs/research/span-grounded-mentions.md`](docs/research/span-grounded-mentions.md) | -| Span-grounded mention numeric provenance | [`docs/doctoring/span-grounded-mentions.md`](docs/doctoring/span-grounded-mentions.md) | -| Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | | Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | | Topic log-ratio coordinate doctoring | [`docs/research/topic-logratio-coordinates.md`](docs/research/topic-logratio-coordinates.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | @@ -95,85 +91,3 @@ The canonical implementation-maturity vocabulary is defined in [`docs/adr/ADR_PO The documentation graph is **design-sufficient** when a reviewer can reconstruct TEPP's product requirements, technical/scientific estimands, authority boundaries, temporal/event/membership semantics, data model, failure modes, security/privacy controls, validation strategy, API/integration contract, operability, research basis, ADR ownership/supersession, and release acceptance without chat history. It is **protected-main-sufficient** only after the canonical documents are integrated on protected `main`, remain semantically current with live code, and their required exact-head documentation/security/review gates pass. An active documentation PR can therefore be design-sufficient while the protected branch remains documentation-insufficient. - -At the time of this review, protected `main` at `7c29e7c971d7940e1fb3def1ed3aae2d1bc8ad4a` implements immutable evidence records/exact spans, the Rust workspace quality foundation, typed six-clock values/uncertain intervals (merged PR #8), Allen interval algebra and bounded path-consistency (merged PR #9), event ontology/membership, and PostgreSQL persistence through restore-integrity probes. Superseded PRs #5 and #6 remain historical lineage only, and drafts #93, #94, #97, #101, #102, #104, #108, #109, #111, and #112 are superseded non-landable lineage; unmerged or draft PRs are never implemented-main claims. The active-PR `prediction_contradiction` coverage gate requires observed Allen coverage (`during`, `starts`, `finishes`, or `equals`) before unmatched predicted mass may be authorized for promotion; `refuse_promotion` is that authority and not a contradiction-only filter — coverage may authorize promotion but does not convert a forecast into observed fact. Remaining TDT/CHRONOS intelligence, shared-latent topic estimation, GPU kernels, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance stay accepted-target or deployment-owned unless a [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) row records a narrower implemented-main or partial subset. -# TEPP Documentation Map - -TEPP's approved PRD v0.4 and implementation plan are the primary product baseline. This index makes the technical, data, scientific, security/privacy, integration, quality, operating, and assurance contracts discoverable without duplicating that source material. - -| Area | Canonical document | -|---|---| -| Approved product requirements | [`docs/product/prd-v0.4-approved.md`](docs/product/prd-v0.4-approved.md) | -| Live product and technical gap baseline | [`docs/product-technical-gap-baseline.md`](docs/product-technical-gap-baseline.md) | -| Whole-conversation documentation fitness | [`docs/DOCUMENTATION_ASSESSMENT.md`](docs/DOCUMENTATION_ASSESSMENT.md) | -| Technical requirements | [`docs/TRD.md`](docs/TRD.md) | -| Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | -| Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | -| naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | -| contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | -| UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | -| Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | -| Security policy | [`SECURITY.md`](SECURITY.md) | -| Threat model | [`docs/THREAT_MODEL.md`](docs/THREAT_MODEL.md) | -| Privacy and data governance | [`docs/PRIVACY_DATA_GOVERNANCE.md`](docs/PRIVACY_DATA_GOVERNANCE.md) | -| Compliance/assurance readiness | [`docs/COMPLIANCE_READINESS.md`](docs/COMPLIANCE_READINESS.md) | -| LLM orchestration/test-time compute | [`docs/LLM_ORCHESTRATION.md`](docs/LLM_ORCHESTRATION.md) | -| Test/scientific validation strategy | [`docs/TEST_STRATEGY.md`](docs/TEST_STRATEGY.md) | -| Operability/recovery/release | [`docs/OPERABILITY.md`](docs/OPERABILITY.md) | -| Requirement/research/evidence traceability | [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md) | -| Architecture decision index / ownership map | [`docs/adr/README.md`](docs/adr/README.md) | -| ADR status, maturity, and supersession policy | [`docs/adr/ADR_POLICY.md`](docs/adr/ADR_POLICY.md) | -| Delivery roadmap | [`docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md`](docs/roadmaps/2026-08-05-tepp-delivery-roadmap.md) | -| Foundation implementation plan | [`docs/superpowers/plans/2026-08-05-temporal-event-foundation.md`](docs/superpowers/plans/2026-08-05-temporal-event-foundation.md) | -| Foundation validation ledger | [`docs/validation/temporal-event-foundation.md`](docs/validation/temporal-event-foundation.md) | -| Standards and APA 7 literature | [`docs/research/standards-and-literature.md`](docs/research/standards-and-literature.md) | -| Operational log / source-separation doctoring | [`docs/research/operational-log-source-separation.md`](docs/research/operational-log-source-separation.md) | -| Interval cutoff eligibility doctoring | [`docs/research/interval-cutoff-eligibility.md`](docs/research/interval-cutoff-eligibility.md) | -| Governance | [`GOVERNANCE.md`](GOVERNANCE.md) | -| Agent development rules | [`AGENTS.md`](AGENTS.md) | -| Agent context | [`CLAUDE.md`](CLAUDE.md) | -| 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) | -| Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | -| Span-grounded mention extent doctoring | [`docs/research/span-grounded-mentions.md`](docs/research/span-grounded-mentions.md) | -| Span-grounded mention numeric provenance | [`docs/doctoring/span-grounded-mentions.md`](docs/doctoring/span-grounded-mentions.md) | -| Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | -| VRAM budget / GPU fallback doctoring | [`docs/research/vram-budget-types.md`](docs/research/vram-budget-types.md) | -| Causal-identification gate doctoring | [`docs/research/causal-identification-gate.md`](docs/research/causal-identification-gate.md) | -| TDT story-segmentation `WindowDiff`/`Pk` doctoring | [`docs/research/tdt-story-segmentation.md`](docs/research/tdt-story-segmentation.md) | -| CHRONOS prediction-calibration doctoring | [`docs/research/chronos-prediction-calibration.md`](docs/research/chronos-prediction-calibration.md) | -| CHRONOS schema-slot calibration doctoring | [`docs/research/chronos-schema-slot-calibration.md`](docs/research/chronos-schema-slot-calibration.md) | -| Event-tracking calibration doctoring | [`docs/research/event-tracking-calibration.md`](docs/research/event-tracking-calibration.md) | -| Episode-membership identity doctoring | [`docs/research/episode-membership-identity.md`](docs/research/episode-membership-identity.md) | -| Entity/project target SQL doctoring | [`docs/research/entity-project-sql.md`](docs/research/entity-project-sql.md) | -| Scientific claim-promotion gate doctoring | [`docs/research/scientific-claim-promotion-gates.md`](docs/research/scientific-claim-promotion-gates.md) | -| Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | -| Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | -| Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | -| Relation absence is not negative evidence | [`docs/research/relation-absence-not-negative.md`](docs/research/relation-absence-not-negative.md) | -| Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | -| Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.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) | - -## Maturity vocabulary - -The canonical implementation-maturity vocabulary is defined in [`docs/adr/ADR_POLICY.md`](docs/adr/ADR_POLICY.md) and promotion evidence in [`docs/TRACEABILITY.md`](docs/TRACEABILITY.md). In particular, **an ADR with decision status `Accepted` is not automatically implemented or shipped.** - -- **implemented-main** — source is integrated on protected `main` and the relevant exact-current-head tests, scientific/recovery/validation evidence, security and supply-chain gates, and qualifying review required by live policy pass. -- **active-PR** — implementation exists only on an open PR and is not a protected-main claim. -- **partial** — an explicitly identified subset is implemented on protected main while the rest remains target work. -- **accepted-target** — accepted PRD/ADR architecture not yet integrated. -- **research-only** — evaluated research direction not accepted as production behavior. -- **out-of-scope** — explicitly outside TEPP ownership. -- **conceptual** — logical entity/service/model contract; not evidence of a migration or deployment. -- **deployment-owned** — evidence depends on a concrete deployed environment or organization and cannot be claimed by repository design alone. -- **external-assurance** — certification, attestation, legal opinion, or other independent assessment that TEPP cannot self-issue. - -## Documentation fitness - -The documentation graph is **design-sufficient** when a reviewer can reconstruct TEPP's product requirements, technical/scientific estimands, authority boundaries, temporal/event/membership semantics, data model, failure modes, security/privacy controls, validation strategy, API/integration contract, operability, research basis, ADR ownership/supersession, and release acceptance without chat history. - -It is **protected-main-sufficient** only after the canonical documents are integrated on protected `main`, remain semantically current with live code, and their required exact-head documentation/security/review gates pass. An active documentation PR can therefore be design-sufficient while the protected branch remains documentation-insufficient. diff --git a/docs/adr/0073-inferred-status-analysis-run.md b/docs/adr/0073-inferred-status-analysis-run.md index 45a1a082b..3630be117 100644 --- a/docs/adr/0073-inferred-status-analysis-run.md +++ b/docs/adr/0073-inferred-status-analysis-run.md @@ -44,6 +44,9 @@ Add the `inferred_status_v1` analysis-run output profile to `observed` / `inferred` vocabulary; - requires a mixed census of at least one `observed` and one `inferred` after cutoff exclusion; +- caps both executable evidence input and serialized/deserialized artifact + claims at `MAX_EVIDENCE_UNITS`, so a compact payload cannot claim a census + no valid run can produce; - emits a canonical SHA-256-digested `tepp.inferred_status.v1` artifact with per-status counts, matching inferred refusal counts, and inference status `inferred_is_not_observed_and_not_transition`; @@ -76,14 +79,14 @@ membership-target, location-membership, membership-posterior ICC, copied-text, copy-identity, citation-edge, subevent containment, method-effect estimation, or topic birth/split/merge. Snapshot / profile / cutoff mismatch, empty or single-class corpora, duplicate -evidence identities, and oversized corpora fail closed. +evidence identities, and oversized corpora or artifact claims fail closed. ## Verification The PR includes Rust unit and integration tests for mixed `observed` / `inferred` corpora, cutoff exclusion, empty/single-class/duplicate refusal, snapshot / profile / cutoff -mismatch, oversize, and artifact tampering. Run: +mismatch, executable and compact-artifact oversize, and artifact tampering. Run: ```text cargo fmt --all -- --check @@ -92,6 +95,14 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` +Then follow the repository-wide exact-head procedure in +[`AGENTS.md`](../../AGENTS.md#pull-request-and-autonomous-execution-loop): the +required hosted CI, documentation, security/supply-chain workflows and +qualifying review must apply to the PR's current exact SHA. Queued, stale or +predecessor-head results are not passing evidence. Because this profile is +`fold_into_landing_vehicle`, its final landing evidence belongs to the coherent +Validation / Analysis Run successor head rather than this micro-profile alone. + ## Rollback and supersession Rollback removes the `inferred_status_v1` profile. No persisted schema From 765e38c85621e054c01a3b8670ec1cdf5e3f17af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:07:42 +0900 Subject: [PATCH 10/75] test(analysis): reject impossible compact membership census --- .../episode_membership_execution_contract.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs index 093df7c29..8041e0ad4 100644 --- a/crates/analysis_engine/tests/episode_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -3,7 +3,8 @@ use analysis_engine::{ AnalysisEngineError, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION, EPISODE_MEMBERSHIP_OUTPUT_PROFILE, - EpisodeMembershipAssignment, MAX_EVIDENCE_UNITS, execute_episode_membership_run, + EpisodeMembershipArtifact, EpisodeMembershipAssignment, MAX_EVIDENCE_UNITS, + execute_episode_membership_run, }; use episode_membership::EventWindow; use temporal_core::{AvailableTime, KnowledgeCutoff}; @@ -113,6 +114,32 @@ fn mixed_windows_emit_digest_bound_refusals_without_recovery_metric() { ); } +#[test] +fn compact_oversized_artifact_counts_fail_closed() { + let assignment_count = MAX_EVIDENCE_UNITS as u64 + 1; + let escaped_count = assignment_count - 1; + let artifact = EpisodeMembershipArtifact { + schema_version: EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-compact-oversize".into(), + snapshot_id: "snapshot-compact-oversize".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count, + contained_count: 1, + escaped_count, + refused_as_escape_count: escaped_count, + inference_status: "membership_window_cannot_escape_episode_interval".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidEpisodeMembershipArtifact) + ); + assert_eq!( + EpisodeMembershipArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidEpisodeMembershipArtifact) + ); +} + #[test] fn future_available_assignments_are_excluded() { let request = request(); From 04a254662fe8bce3f68e6e41a8290ca13d773fdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:08:29 +0900 Subject: [PATCH 11/75] fix(analysis): bound serialized membership census --- crates/analysis_engine/src/episode_membership_artifact.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index b727c9e22..f72e6812c 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -156,6 +156,7 @@ impl EpisodeMembershipArtifact { || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() || self.assignment_count < 2 + || self.assignment_count > MAX_EVIDENCE_UNITS as u64 || self.contained_count == 0 || self.escaped_count == 0 || status_sum != Some(self.assignment_count) From d3fa7276d4e0f0382d2c65e5404438ef26bdf67a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:09:57 +0900 Subject: [PATCH 12/75] 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 13/75] 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; From a4b57c80b6295b276b6180657647ecdfc2f76b1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:14:56 +0900 Subject: [PATCH 14/75] test(analysis): reject impossible location census artifact --- ...tion_membership_artifact_bound_contract.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs diff --git a/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs b/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs new file mode 100644 index 000000000..80d0ed06d --- /dev/null +++ b/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs @@ -0,0 +1,35 @@ +//! Serialization bound for the location-membership analysis artifact. + +use analysis_engine::{ + AnalysisEngineError, LocationMembershipArtifact, MAX_EVIDENCE_UNITS, + LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, +}; + +#[test] +fn compact_oversized_location_artifact_fails_closed() { + let document_count = MAX_EVIDENCE_UNITS as u64 + 1; + let location_count = document_count - 2; + let artifact = LocationMembershipArtifact { + schema_version: LOCATION_MEMBERSHIP_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, + location_count, + entity_identity_count: 1, + language_channel_count: 1, + refused_as_entity_identity_count: location_count, + refused_as_language_channel_count: location_count, + inference_status: "location_is_not_entity_identity_not_language_channel".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidLocationMembershipArtifact) + ); + assert_eq!( + LocationMembershipArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidLocationMembershipArtifact) + ); +} From 5b5791b51705404eb2a650d4bc3278a1bc5e3fa8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:15:42 +0900 Subject: [PATCH 15/75] fix(analysis): bound location-membership artifacts --- crates/analysis_engine/src/location_membership_artifact.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index 672aac829..34d9add7a 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -131,6 +131,9 @@ impl LocationMembershipArtifact { self.validate()?; let payload = serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } Ok(payload) } @@ -157,6 +160,7 @@ impl LocationMembershipArtifact { || !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 || self.location_count == 0 || non_location == Some(0) || kind_sum != Some(self.document_count) From c4c7851047e7fe63fae42215bad8398a743203e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:16:44 +0900 Subject: [PATCH 16/75] test(analysis): reject impossible copy census artifact --- .../copy_identity_artifact_bound_contract.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs diff --git a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs new file mode 100644 index 000000000..c6900be34 --- /dev/null +++ b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs @@ -0,0 +1,92 @@ +//! Execution and serialization bounds for the copy-identity profile. + +use analysis_engine::{ + AnalysisEngineError, CopyIdentityArtifact, CopyIdentityDocument, MAX_EVIDENCE_UNITS, + COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, COPY_IDENTITY_MODEL_CONTRACT_VERSION, + COPY_IDENTITY_OUTPUT_PROFILE, execute_copy_identity_run, +}; +use copy_identity::CopyKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn document(id: String, kind: CopyKind) -> CopyIdentityDocument { + CopyIdentityDocument::new( + id, + kind, + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + ) + .expect("document") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "copy-bound-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-copy-bound".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: COPY_IDENTITY_MODEL_CONTRACT_VERSION.into(), + output_profile: COPY_IDENTITY_OUTPUT_PROFILE.into(), + } +} + +#[test] +fn compact_oversized_copy_artifact_fails_closed() { + let document_count = MAX_EVIDENCE_UNITS as u64 + 1; + let template_copy_count = document_count - 1; + let artifact = CopyIdentityArtifact { + schema_version: COPY_IDENTITY_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, + source_document_count: 1, + template_copy_count, + refused_as_source_count: template_copy_count, + refused_as_transition_count: template_copy_count, + inference_status: "template_copy_is_not_source_identity_not_transition".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidCopyIdentityArtifact) + ); + assert_eq!( + CopyIdentityArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidCopyIdentityArtifact) + ); +} + +#[test] +fn oversized_copy_execution_fails_before_census() { + let request = request(); + let accepted = AnalysisRunAccepted::new("run-copy-bound", "accepted", &request.idempotency_key) + .expect("accepted"); + let documents = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + let kind = if index == 0 { + CopyKind::SourceDocument + } else { + CopyKind::TemplateCopy + }; + document(format!("document-{index}"), kind) + }) + .collect::>(); + + assert_eq!( + execute_copy_identity_run( + &request, + &accepted, + "snapshot-copy-bound", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::LimitExceeded) + ); +} From d5c9811f3d0547fab811252d27c202d011759048 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:17:42 +0900 Subject: [PATCH 17/75] fix(analysis): bound copy-identity artifacts --- .../analysis_engine/src/copy_identity_artifact.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index e7e904e65..4d3907f50 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -11,7 +11,10 @@ use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; -use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; /// Versioned schema for a completed copy-identity artifact. pub const COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION: &str = "tepp.copy_identity.v1"; @@ -125,6 +128,9 @@ impl CopyIdentityArtifact { self.validate()?; let payload = serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > COPY_IDENTITY_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } Ok(payload) } @@ -147,6 +153,7 @@ impl CopyIdentityArtifact { || !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 || self.source_document_count == 0 || self.template_copy_count == 0 || kind_sum != Some(self.document_count) @@ -179,7 +186,8 @@ pub struct CopyIdentityExecution { /// # Errors /// /// Returns a request/receipt/snapshot/cutoff/profile error, empty or -/// single-kind corpus, duplicate document identity, or invalid artifact error. +/// single-kind corpus, duplicate document identity, oversized corpus, or +/// invalid artifact error. pub fn execute_copy_identity_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -200,6 +208,9 @@ pub fn execute_copy_identity_run( { return Err(AnalysisEngineError::InvalidEvidence); } + if documents.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } let mut seen = std::collections::BTreeSet::new(); let mut source_document_count = 0_u64; From b7312eed81e25aba586323be058077dabc38a2df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:04:23 +0900 Subject: [PATCH 18/75] test(analysis): reproduce equivalent cutoff offset rejection --- .../tests/copy_identity_execution_contract.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/analysis_engine/tests/copy_identity_execution_contract.rs b/crates/analysis_engine/tests/copy_identity_execution_contract.rs index 2d317d5b5..4964883cf 100644 --- a/crates/analysis_engine/tests/copy_identity_execution_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_execution_contract.rs @@ -95,6 +95,22 @@ fn mixed_copy_kinds_emit_digest_bound_refusals_without_recovery_metric() { ); } +#[test] +fn equivalent_cutoff_offsets_are_compared_as_instants() { + let mut offset_request = request(); + offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); + let execution = execute_copy_identity_run( + &offset_request, + &accepted(&offset_request), + "snapshot-copy-identity", + cutoff(), + &mixed_documents(), + "2026-08-02T00:00:00Z", + ) + .expect("equivalent RFC 3339 offsets denote the same cutoff instant"); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00Z"); +} + #[test] fn empty_source_only_copy_only_and_duplicate_identities_fail_closed() { let request = request(); From a6ba5d79c8d9acc1e7b4a53b3be5ef3d70ab19e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 09:05:00 +0900 Subject: [PATCH 19/75] fix(analysis): compare knowledge cutoffs by instant --- crates/analysis_engine/src/copy_identity_artifact.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index 4d3907f50..8b18894e4 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -202,7 +202,9 @@ pub fn execute_copy_identity_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != COPY_IDENTITY_MODEL_CONTRACT_VERSION || request.output_profile != COPY_IDENTITY_OUTPUT_PROFILE { From 3b6049d63064cd8783fa0b2f3b589b2020fe32eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:03:03 +0900 Subject: [PATCH 20/75] test(analysis): reproduce location cutoff offset equivalence --- ..._membership_cutoff_equivalence_contract.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 crates/analysis_engine/tests/location_membership_cutoff_equivalence_contract.rs diff --git a/crates/analysis_engine/tests/location_membership_cutoff_equivalence_contract.rs b/crates/analysis_engine/tests/location_membership_cutoff_equivalence_contract.rs new file mode 100644 index 000000000..c0d5e351c --- /dev/null +++ b/crates/analysis_engine/tests/location_membership_cutoff_equivalence_contract.rs @@ -0,0 +1,57 @@ +//! Regression for semantically equivalent RFC 3339 cutoff spellings. + +use analysis_engine::{ + LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION, LOCATION_MEMBERSHIP_OUTPUT_PROFILE, + LocationMembershipDocument, execute_location_membership_run, +}; +use location_membership::LocationKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn available(value: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(value).expect("availability") +} + +#[test] +fn equivalent_offset_cutoff_is_the_same_analysis_instant() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "location-membership-cutoff-equivalence".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-location-membership".into(), + knowledge_cutoff: "2026-08-01T09:00:00+09:00".into(), + model_contract_version: LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION.into(), + output_profile: LOCATION_MEMBERSHIP_OUTPUT_PROFILE.into(), + }; + let accepted = AnalysisRunAccepted::new( + "run-location-membership-cutoff-equivalence", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + let cutoff = KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"); + let documents = vec![ + LocationMembershipDocument::new( + "loc-a", + LocationKind::Location, + available("2026-07-31T23:59:59Z"), + ) + .expect("location"), + LocationMembershipDocument::new( + "ent-b", + LocationKind::EntityIdentity, + available("2026-07-31T23:59:59Z"), + ) + .expect("entity"), + ]; + + execute_location_membership_run( + &request, + &accepted, + "snapshot-location-membership", + cutoff, + &documents, + "2026-08-02T00:00:00Z", + ) + .expect("equivalent cutoff instants must be admitted"); +} From 8bbe557bb63f632c4edddb0d3c3bdd96fe54f9f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 11:03:56 +0900 Subject: [PATCH 21/75] fix(analysis): compare location cutoff instants --- crates/analysis_engine/src/location_membership_artifact.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index 34d9add7a..6d40bfa65 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -208,7 +208,9 @@ pub fn execute_location_membership_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION || request.output_profile != LOCATION_MEMBERSHIP_OUTPUT_PROFILE { From f1124c5a34f7b71f7c4f10d64fe6f541e4187890 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:14:43 +0000 Subject: [PATCH 22/75] feat(analysis): bind subevent-containment refusals to an analysis-run profile GAP-004 leftover / ADR 0074. Bind existing subevent_containment::EventInterval and refuse_escaped_subevent to cutoff-safe subevent_containment_v1 (tepp.subevent_containment.v1). Mixed contained/escaped corpora emit a digest-bound census; MAX_EVIDENCE_UNITS is enforced on execution and artifact validation from the first commit. identity_recovery_rate and containment_recovery_rate stay library-side. Distinct from episode-membership, inferred-status, relation-absence, outcome-order, membership-target, and location-membership. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 1 + crates/analysis_engine/src/lib.rs | 15 + .../src/subevent_containment_artifact.rs | 409 ++++++++++++++++++ ...subevent_containment_execution_contract.rs | 280 ++++++++++++ docs/TRACEABILITY.md | 1 + .../0074-subevent-containment-analysis-run.md | 108 +++++ docs/adr/README.md | 2 + .../subevent-containment-analysis-run.md | 19 + 11 files changed, 839 insertions(+) create mode 100644 crates/analysis_engine/src/subevent_containment_artifact.rs create mode 100644 crates/analysis_engine/tests/subevent_containment_execution_contract.rs create mode 100644 docs/adr/0074-subevent-containment-analysis-run.md create mode 100644 docs/doctoring/subevent-containment-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..1e3720a54 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] +- **Subevent-containment analysis-run profile**: `analysis_engine` binds existing `subevent_containment::EventInterval` and `refuse_escaped_subevent` to cutoff-safe `subevent_containment_v1` (`tepp.subevent_containment.v1`) with inference status `subevent_interval_cannot_escape_parent_interval`. `containment_recovery_rate` and `identity_recovery_rate` stay library-side. Not episode-membership, not inferred-status, not relation-absence, not outcome-order, not membership-target, not location-membership, not membership-posterior ICC, not copied-text, not copy-identity, not citation-edge, 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..2a470ae5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -78,6 +78,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "subevent_containment", "temporal_core", "tepp_api", "topic_measurement", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..60f65784a 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) | +| Subevent-containment analysis-run doctoring | [`docs/doctoring/subevent-containment-analysis-run.md`](docs/doctoring/subevent-containment-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..f6ea2edd6 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" } +subevent_containment = { path = "../subevent_containment", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..239f7ef47 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 subevent_containment_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +47,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Subevent-containment artifact and execution contracts from this engine. +pub use subevent_containment_artifact::{ + SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT, SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, + SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION, SUBEVENT_CONTAINMENT_OUTPUT_PROFILE, + SubeventContainmentArtifact, SubeventContainmentAssignment, SubeventContainmentExecution, + execute_subevent_containment_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 subevent-containment artifact violated its bounded schema or count invariants. + InvalidSubeventContainmentArtifact, } 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::InvalidSubeventContainmentArtifact => "invalid subevent-containment artifact", }; formatter.write_str(message) } @@ -681,6 +692,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidSubeventContainmentArtifact, + "invalid subevent-containment artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs new file mode 100644 index 000000000..85124105c --- /dev/null +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -0,0 +1,409 @@ +//! Digest-bound subevent-containment refusals as an analysis-run profile. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use subevent_containment::{EventInterval, SubeventContainmentError, refuse_escaped_subevent}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed subevent-containment artifact. +pub const SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION: &str = "tepp.subevent_containment.v1"; +/// Model contract required by the subevent-containment execution path. +pub const SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION: &str = "subevent_containment_v1"; +/// Analysis-run output profile required for a subevent-containment artifact. +pub const SUBEVENT_CONTAINMENT_OUTPUT_PROFILE: &str = "subevent_containment_v1"; +/// Maximum canonical artifact JSON size. +pub const SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const SUBEVENT_CONTAINMENT_INFERENCE_STATUS: &str = + "subevent_interval_cannot_escape_parent_interval"; + +/// One cutoff-admitted child interval against a parent interval. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SubeventContainmentAssignment { + assignment_id: String, + parent: EventInterval, + child: EventInterval, + available_time: AvailableTime, +} + +impl SubeventContainmentAssignment { + /// Construct a bounded subevent-containment assignment. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the assignment + /// identity is empty or oversized. + pub fn new( + assignment_id: impl Into, + parent: EventInterval, + child: EventInterval, + available_time: AvailableTime, + ) -> Result { + let assignment_id = assignment_id.into(); + if !valid_identifier(&assignment_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + assignment_id, + parent, + child, + available_time, + }) + } + + /// Return the opaque assignment identity. + #[must_use] + pub fn assignment_id(&self) -> &str { + &self.assignment_id + } + + /// Return the parent event-time interval. + #[must_use] + pub const fn parent(&self) -> EventInterval { + self.parent + } + + /// Return the child subevent interval. + #[must_use] + pub const fn child(&self) -> EventInterval { + self.child + } + + /// Return the availability time used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded subevent-containment census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SubeventContainmentArtifact { + /// 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 assignments. + pub knowledge_cutoff: String, + /// Number of assignments admitted at the cutoff. + pub assignment_count: u64, + /// Assignments contained in their parent interval. + pub contained_count: u64, + /// Assignments that escaped their parent interval. + pub escaped_count: u64, + /// Escaped assignments refused as subevent-outside-parent. + pub refused_as_escape_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl SubeventContainmentArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidSubeventContainmentArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidSubeventContainmentArtifact)?; + 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() > SUBEVENT_CONTAINMENT_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 status_sum = self.contained_count.checked_add(self.escaped_count); + if self.schema_version != SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.assignment_count < 2 + || self.assignment_count > MAX_EVIDENCE_UNITS as u64 + || self.contained_count == 0 + || self.escaped_count == 0 + || status_sum != Some(self.assignment_count) + || self.refused_as_escape_count != self.escaped_count + || self.inference_status != SUBEVENT_CONTAINMENT_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidSubeventContainmentArtifact); + } + Ok(()) + } +} + +/// One completed subevent-containment artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct SubeventContainmentExecution { + /// Digest-bound completed subevent-containment census. + pub artifact: SubeventContainmentArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe subevent-containment refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_escaped_subevent`] already on protected +/// main. Contained children stay attachments. Escaped children stay refusals, +/// never episode-membership windows. 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, inverted or escaped subevent treated as success, +/// duplicate assignment identity, oversized corpus, or invalid artifact +/// error. +pub fn execute_subevent_containment_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + assignments: &[SubeventContainmentAssignment], + 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 != SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION + || request.output_profile != SUBEVENT_CONTAINMENT_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if assignments.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let (contained_count, escaped_count, refused_as_escape_count) = + census_assignments(assignments, knowledge_cutoff)?; + let assignment_count = contained_count + .checked_add(escaped_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + if assignment_count < 2 + || contained_count == 0 + || escaped_count == 0 + || refused_as_escape_count != escaped_count + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = SubeventContainmentArtifact { + schema_version: SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + assignment_count, + contained_count, + escaped_count, + refused_as_escape_count, + inference_status: SUBEVENT_CONTAINMENT_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "subevent_containment", + assignment_count, + 4, + SUBEVENT_CONTAINMENT_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("subevent_containment_artifact_{}", &digest[..16]), + digest, + SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(SubeventContainmentExecution { + artifact, + terminal_result, + }) +} + +fn census_assignments( + assignments: &[SubeventContainmentAssignment], + knowledge_cutoff: KnowledgeCutoff, +) -> Result<(u64, u64, u64), AnalysisEngineError> { + let mut seen = std::collections::BTreeSet::new(); + let mut contained_count = 0_u64; + let mut escaped_count = 0_u64; + let mut refused_as_escape_count = 0_u64; + for assignment in assignments { + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + if assignment.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + match refuse_escaped_subevent(assignment.parent(), assignment.child()) { + Ok(()) => { + contained_count = increment(contained_count)?; + } + Err(SubeventContainmentError::SubeventEscapesParent) => { + refused_as_escape_count = increment(refused_as_escape_count)?; + escaped_count = increment(escaped_count)?; + } + Err(SubeventContainmentError::InvalidIntervalPayload | _) => { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + } + Ok((contained_count, escaped_count, refused_as_escape_count)) +} + +fn increment(count: u64) -> Result { + count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + +#[cfg(test)] +mod tests { + use super::{ + SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT, SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, + SUBEVENT_CONTAINMENT_INFERENCE_STATUS, SubeventContainmentArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> SubeventContainmentArtifact { + SubeventContainmentArtifact { + schema_version: SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count: 2, + contained_count: 1, + escaped_count: 1, + refused_as_escape_count: 1, + inference_status: SUBEVENT_CONTAINMENT_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &SubeventContainmentArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidSubeventContainmentArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + SubeventContainmentArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + SubeventContainmentArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidSubeventContainmentArtifact) + ); + assert_eq!( + SubeventContainmentArtifact::from_json( + &"x".repeat(SUBEVENT_CONTAINMENT_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.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.contained_count = 0; + value.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.escaped_count = 0; + value.refused_as_escape_count = 0; + value.assignment_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_escape_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/subevent_containment_execution_contract.rs b/crates/analysis_engine/tests/subevent_containment_execution_contract.rs new file mode 100644 index 000000000..98dbff773 --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_execution_contract.rs @@ -0,0 +1,280 @@ +//! End-to-end contract for cutoff-safe subevent-containment refusals. + +use analysis_engine::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, + SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION, SUBEVENT_CONTAINMENT_OUTPUT_PROFILE, + SubeventContainmentArtifact, SubeventContainmentAssignment, execute_subevent_containment_run, +}; +use subevent_containment::EventInterval; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn interval(start: i64, end: i64) -> EventInterval { + EventInterval::new(start, end).expect("interval") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "subevent-containment-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-subevent-containment".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION.into(), + output_profile: SUBEVENT_CONTAINMENT_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-subevent-containment", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn assignment( + assignment_id: &str, + parent: EventInterval, + child: EventInterval, + stamp: &str, +) -> SubeventContainmentAssignment { + SubeventContainmentAssignment::new(assignment_id, parent, child, available(stamp)) + .expect("assignment") +} + +fn mixed_assignments() -> Vec { + let parent = interval(10, 40); + vec![ + assignment( + "contained-a", + parent, + interval(15, 30), + "2026-07-01T00:00:00Z", + ), + assignment("escaped-b", parent, interval(0, 20), "2026-07-02T00:00:00Z"), + ] +} + +fn execute( + request: &AnalysisRunRequest, + assignments: &[SubeventContainmentAssignment], +) -> Result { + execute_subevent_containment_run( + request, + &accepted(request), + "snapshot-subevent-containment", + cutoff(), + assignments, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_intervals_emit_digest_bound_refusals_without_recovery_metric() { + let request = request(); + let execution = execute(&request, &mixed_assignments()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.contained_count, 1); + assert_eq!(execution.artifact.escaped_count, 1); + assert_eq!(execution.artifact.refused_as_escape_count, 1); + assert_eq!( + execution.artifact.inference_status, + "subevent_interval_cannot_escape_parent_interval" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + assert!(!payload.contains("episode_membership")); + 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(SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn compact_oversized_artifact_counts_fail_closed() { + let assignment_count = MAX_EVIDENCE_UNITS as u64 + 1; + let escaped_count = assignment_count - 1; + let artifact = SubeventContainmentArtifact { + schema_version: SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-compact-oversize".into(), + snapshot_id: "snapshot-compact-oversize".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count, + contained_count: 1, + escaped_count, + refused_as_escape_count: escaped_count, + inference_status: "subevent_interval_cannot_escape_parent_interval".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidSubeventContainmentArtifact) + ); + assert_eq!( + SubeventContainmentArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidSubeventContainmentArtifact) + ); +} + +#[test] +fn future_available_assignments_are_excluded() { + let request = request(); + let parent = interval(10, 40); + let mut with_future = mixed_assignments(); + with_future.push(assignment( + "future-c", + parent, + interval(15, 18), + "2026-08-02T00:00:00Z", + )); + let execution = execute(&request, &with_future).expect("cutoff"); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.contained_count, 1); +} + +#[test] +fn empty_or_single_class_and_duplicate_fail_closed() { + let request = request(); + let parent = interval(10, 40); + let stamp = "2026-07-01T00:00:00Z"; + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let contained_only = vec![ + assignment("contained-a", parent, interval(15, 18), stamp), + assignment("contained-b", parent, interval(20, 25), stamp), + ]; + assert_eq!( + execute(&request, &contained_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let escaped_only = vec![ + assignment("escaped-a", parent, interval(0, 5), stamp), + assignment("escaped-b", parent, interval(40, 50), stamp), + ]; + assert_eq!( + execute(&request, &escaped_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + assignment("same", parent, interval(15, 18), stamp), + assignment("same", parent, interval(0, 5), stamp), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + SubeventContainmentAssignment::new("", parent, interval(15, 18), available(stamp)), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { + let request = request(); + let assignments = mixed_assignments(); + assert_eq!( + execute_subevent_containment_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &assignments, + "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_subevent_containment_run( + &mismatched, + &accepted(&mismatched), + "snapshot-subevent-containment", + cutoff(), + &assignments, + "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", + "membership_target_v1", + "outcome_order_v1", + "relation_absence_v1", + "episode_membership_v1", + "inferred_status_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_subevent_containment_run( + &reused, + &accepted(&reused), + "snapshot-subevent-containment", + cutoff(), + &assignments, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + let parent = interval(10, 40); + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + assignment( + &format!("assignment-{index}"), + parent, + interval(15, 18), + "2026-07-01T00:00:00Z", + ) + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..f8cf87fd3 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 | +| subevent-containment analysis-run profile | ADR 0003/0022/0074; subevent intervals cannot escape the parent interval | `analysis_engine` `subevent_containment_v1` binds `refuse_escaped_subevent`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not episode-membership, not inferred-status, not relation-absence, not outcome-order, not membership-target, not location-membership, 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/0074-subevent-containment-analysis-run.md b/docs/adr/0074-subevent-containment-analysis-run.md new file mode 100644 index 000000000..5e1289045 --- /dev/null +++ b/docs/adr/0074-subevent-containment-analysis-run.md @@ -0,0 +1,108 @@ +# ADR 0074 — Subevent-containment refusals as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-09-02 +**Supersedes:** None; complements ADR 0003 (a subevent interval cannot escape the parent event-time interval) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0073 (inferred-status), ADR 0072 (episode-membership), ADR 0071 (relation-absence), ADR 0070 (outcome-order), ADR 0069 (membership-target), ADR 0068 (topic-context posterior), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0064 (provenance-is-not-transition / citation-edge), or ADR 0058 (copy-identity / template-copy). This is subevent-versus-parent containment, not episode-membership-window containment. +**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 a half-open subevent interval that starts +before or ends after its parent, via `subevent_containment::EventInterval` +and `refuse_escaped_subevent`. Operators still cannot request that census +as a digest-bound analysis-run output. + +Episode-membership (#461 / ADR 0072) binds membership windows to episode +intervals. Inferred-status (#473 / ADR 0073) binds inferred-versus-observed +refusals. Relation-absence (#460 / ADR 0071) binds observation status. +Outcome-order (#458 / ADR 0070) binds IPO event-time order. +Membership-target (#434 / ADR 0069) binds `MembershipTargetKind`. +Location-membership (#430 / ADR 0066) binds geographic/market assignment. + +`containment_recovery_rate` and `identity_recovery_rate` stay library-side. +This slice does not put a `scientific_acceptance` metric on inspect +payloads. Episode-window containment remains `episode_membership`. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `subevent_containment_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `SubeventContainmentAssignment` rows with + half-open `EventInterval` parent/child bounds and availability time; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- excludes assignments whose availability is later than the knowledge + cutoff; +- invokes `refuse_escaped_subevent` without reimplementing the + containment vocabulary; +- requires a mixed census of at least one contained and one escaped + assignment after cutoff exclusion; +- emits a canonical SHA-256-digested `tepp.subevent_containment.v1` artifact + with contained/escaped counts, matching escape-refusal counts, and + inference status `subevent_interval_cannot_escape_parent_interval`; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate episode-membership (#461 / ADR 0072) — rejected because that + profile binds membership windows to episode intervals, not + subevent-versus-parent containment. +2. Duplicate inferred-status (#473 / ADR 0073) — rejected because that + profile binds inferred-versus-observed status. +3. Duplicate relation-absence (#460 / ADR 0071) — rejected because that + profile binds observation status. +4. Duplicate outcome-order (#458 / ADR 0070) — rejected because that + profile binds IPO event-time order. +5. Duplicate membership-target (#434 / ADR 0069) — rejected because that + profile binds `MembershipTargetKind`. +6. Duplicate location-membership (#430 / ADR 0066) — rejected because + that profile binds `location_membership` LocationKind refusals. +7. Put `containment_recovery_rate` or `identity_recovery_rate` on the + operator artifact — rejected because inspect payloads stay metric-free + and `tepp.scientific_acceptance.v1` never appears. +8. Bind the existing subevent-containment refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe subevent-containment refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, episode-membership, inferred-status, relation-absence, +outcome-order, membership-target, location-membership, +membership-posterior ICC, copied-text, copy-identity, citation-edge, +method-effect estimation, or topic birth/split/merge. Snapshot / profile +/ cutoff mismatch, empty or single-class corpora, duplicate assignment +identities, and oversized corpora fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed +contained/escaped corpora, cutoff exclusion, empty/single-class/duplicate +refusal, snapshot / profile / cutoff mismatch, oversize, compact +`MAX_EVIDENCE_UNITS + 1` artifact refusal, 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 +``` + +Exact-head hosted Rust Foundation CI, Documentation Quality, Security +Scan, and SAST Semgrep on this branch head are required landing evidence. +Predecessor-head checks do not transfer. + +## Rollback and supersession + +Rollback removes the `subevent_containment_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps subevent +intervals inside the parent interval, keeps this distinct from +episode-membership-window containment, and keeps recovery rates off +inspect payloads. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..1297aaf33 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [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. | +| [0074](0074-subevent-containment-analysis-run.md) | Subevent-containment refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0003/0022; `EventInterval` + `refuse_escaped_subevent`, not episode-membership, not inferred-status. | | [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. | | [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. | @@ -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. +- **subevent-containment analysis-run profile:** ADR 0074. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/subevent-containment-analysis-run.md b/docs/doctoring/subevent-containment-analysis-run.md new file mode 100644 index 000000000..01861deaa --- /dev/null +++ b/docs/doctoring/subevent-containment-analysis-run.md @@ -0,0 +1,19 @@ +# Subevent-containment analysis-run composition + +**Active slice:** ADR 0074 / `subevent_containment_v1` +**Protected-main status:** not implemented-main + +`subevent_containment` already refuses a half-open child interval that +starts before or ends after its parent. This slice binds `EventInterval` +and `refuse_escaped_subevent` to a cutoff-safe analysis-run profile so +operators can request a digest-bound identity artifact. + +The artifact inference status is +`subevent_interval_cannot_escape_parent_interval`. +`containment_recovery_rate` and `identity_recovery_rate` stay +library-side. This is subevent-versus-parent containment, not +episode-membership-window containment. This is not inferred-status, not +relation-absence, not outcome-order, not membership-target, not +location-membership, not membership-posterior ICC, not copied-text, not +copy-identity, not citation-edge, not GPU, not MCMC, and not topic +birth/split/merge. From 94daa4790715f113275faa78d3fc0d2f4b49298e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:01:00 +0900 Subject: [PATCH 23/75] test(analysis): reproduce subevent containment contract defects --- ...ubevent_containment_regression_contract.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 crates/analysis_engine/tests/subevent_containment_regression_contract.rs diff --git a/crates/analysis_engine/tests/subevent_containment_regression_contract.rs b/crates/analysis_engine/tests/subevent_containment_regression_contract.rs new file mode 100644 index 000000000..032636793 --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_regression_contract.rs @@ -0,0 +1,93 @@ +//! Regression contracts for subevent-containment analysis-run boundaries. + +use analysis_engine::{ + SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION, SUBEVENT_CONTAINMENT_OUTPUT_PROFILE, + SubeventContainmentAssignment, execute_subevent_containment_run, +}; +use subevent_containment::EventInterval; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn interval(start: i64, end: i64) -> EventInterval { + EventInterval::new(start, end).expect("interval") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available time") +} + +fn request(cutoff: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "subevent-containment-regression".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-subevent-containment".into(), + knowledge_cutoff: cutoff.into(), + model_contract_version: SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION.into(), + output_profile: SUBEVENT_CONTAINMENT_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-subevent-containment", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn assignments() -> Vec { + let parent = interval(10, 40); + vec![ + SubeventContainmentAssignment::new( + "contained", + parent, + interval(15, 30), + available("2026-07-01T00:00:00Z"), + ) + .expect("contained assignment"), + SubeventContainmentAssignment::new( + "escaped", + parent, + interval(0, 20), + available("2026-07-02T00:00:00Z"), + ) + .expect("escaped assignment"), + ] +} + +#[test] +fn equivalent_rfc3339_cutoff_spellings_bind_to_the_same_instant() { + let request = request("2026-08-01T09:00:00+09:00"); + let execution = execute_subevent_containment_run( + &request, + &accepted(&request), + "snapshot-subevent-containment", + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + &assignments(), + "2026-08-02T00:00:00Z", + ) + .expect("equivalent cutoff instants must bind"); + assert_eq!(execution.artifact.assignment_count, 2); +} + +#[test] +fn terminal_summary_keeps_validation_state_separate_from_inference_status() { + let request = request("2026-08-01T00:00:00Z"); + let execution = execute_subevent_containment_run( + &request, + &accepted(&request), + "snapshot-subevent-containment", + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + &assignments(), + "2026-08-02T00:00:00Z", + ) + .expect("execution"); + let summary = execution + .terminal_result + .summary + .as_ref() + .expect("succeeded summary"); + assert_eq!(summary.validation_status, "validated"); + assert_eq!( + execution.artifact.inference_status, + "subevent_interval_cannot_escape_parent_interval" + ); +} From c8d7d17ee6b2e916d6495f626f64f680e66830d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 12:01:41 +0900 Subject: [PATCH 24/75] fix(analysis): separate containment validation and cutoff semantics --- crates/analysis_engine/src/subevent_containment_artifact.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs index 85124105c..427d6442c 100644 --- a/crates/analysis_engine/src/subevent_containment_artifact.rs +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -206,7 +206,9 @@ pub fn execute_subevent_containment_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION || request.output_profile != SUBEVENT_CONTAINMENT_OUTPUT_PROFILE { @@ -245,7 +247,7 @@ pub fn execute_subevent_containment_run( "subevent_containment", assignment_count, 4, - SUBEVENT_CONTAINMENT_INFERENCE_STATUS, + "validated", )?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, From 560512a5b86a166502d0ebfbb83968cce753969b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:06:35 +0900 Subject: [PATCH 25/75] test(analysis): reproduce terminal validation-status semantic drift --- ...est_analysis_terminal_validation_status.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/quality/test_analysis_terminal_validation_status.py diff --git a/tests/quality/test_analysis_terminal_validation_status.py b/tests/quality/test_analysis_terminal_validation_status.py new file mode 100644 index 000000000..bf5183542 --- /dev/null +++ b/tests/quality/test_analysis_terminal_validation_status.py @@ -0,0 +1,23 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ANALYSIS_ENGINE = REPO_ROOT / "crates" / "analysis_engine" / "src" + +PROFILE_CONTRACTS = { + "copy_identity_artifact.rs": "COPY_IDENTITY_INFERENCE_STATUS", + "inferred_status_artifact.rs": "INFERRED_STATUS_INFERENCE_STATUS", + "location_membership_artifact.rs": "LOCATION_MEMBERSHIP_INFERENCE_STATUS", + "episode_membership_artifact.rs": "EPISODE_MEMBERSHIP_INFERENCE_STATUS", + "subevent_containment_artifact.rs": "SUBEVENT_CONTAINMENT_INFERENCE_STATUS", + "membership_target_artifact.rs": "MEMBERSHIP_TARGET_INFERENCE_STATUS", +} + + +def test_domain_inference_claims_do_not_occupy_terminal_validation_status() -> None: + for filename, inference_constant in PROFILE_CONTRACTS.items(): + source = (ANALYSIS_ENGINE / filename).read_text(encoding="utf-8") + assert f"inference_status: {inference_constant}.into()" in source + assert f"validation_status: {inference_constant}.into()" not in source + assert f", {inference_constant},\n )?;" not in source + assert '"validated"' in source From 5f67b9721faa94453daaaa3194cc85e223c3fa3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:08:17 +0900 Subject: [PATCH 26/75] fix(analysis): separate copy inference from validation status --- crates/analysis_engine/src/copy_identity_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index 8b18894e4..c6b79f414 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -271,7 +271,7 @@ pub fn execute_copy_identity_run( }; let digest = artifact.sha256()?; #[rustfmt::skip] - let summary = AnalysisResultSummary::new("copy_identity", document_count, 4, COPY_IDENTITY_INFERENCE_STATUS)?; + let summary = AnalysisResultSummary::new("copy_identity", document_count, 4, "validated")?; #[rustfmt::skip] let terminal_result = AnalysisRunTerminalResult::succeeded(request, accepted, format!("copy_identity_artifact_{}", &digest[..16]), digest, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, completed_at, summary)?; Ok(CopyIdentityExecution { From 30d1709329c3cd155f02b63c7e07a1e13dfea0a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:09:14 +0900 Subject: [PATCH 27/75] fix(analysis): separate inferred evidence claim from validation status --- crates/analysis_engine/src/inferred_status_artifact.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/analysis_engine/src/inferred_status_artifact.rs b/crates/analysis_engine/src/inferred_status_artifact.rs index e517c7973..5b8cc1de2 100644 --- a/crates/analysis_engine/src/inferred_status_artifact.rs +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -241,12 +241,7 @@ pub fn execute_inferred_status_run( inference_status: INFERRED_STATUS_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "inferred_status", - evidence_count, - 4, - INFERRED_STATUS_INFERENCE_STATUS, - )?; + let summary = AnalysisResultSummary::new("inferred_status", evidence_count, 4, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, From 17ac1a0aaabc810f0484dfce1c1f2ceb965a455c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:11:39 +0900 Subject: [PATCH 28/75] fix(analysis): separate location inference from validation status --- crates/analysis_engine/src/location_membership_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index 6d40bfa65..cf404871e 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -278,7 +278,7 @@ pub fn execute_location_membership_run( analysis_family: "location_membership".into(), evidence_count: document_count, statistic_count: 5, - validation_status: LOCATION_MEMBERSHIP_INFERENCE_STATUS.into(), + validation_status: "validated".into(), }; let terminal_result = AnalysisRunTerminalResult::succeeded( request, From d5e42069e921450c0b479774dd8c6da9aecc57fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:12:28 +0900 Subject: [PATCH 29/75] test(analysis): reproduce episode cutoff and validation drift --- .../episode_membership_execution_contract.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs index 8041e0ad4..b25dff2e1 100644 --- a/crates/analysis_engine/tests/episode_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -104,6 +104,15 @@ fn mixed_windows_emit_digest_bound_refusals_without_recovery_metric() { execution.terminal_result.run_state, AnalysisRunTerminalState::Succeeded ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); assert_eq!( execution.terminal_result.result_sha256.as_deref(), Some(execution.artifact.sha256().expect("digest").as_str()) @@ -114,6 +123,14 @@ fn mixed_windows_emit_digest_bound_refusals_without_recovery_metric() { ); } +#[test] +fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { + let mut offset_request = request(); + offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); + let execution = execute(&offset_request, &mixed_assignments()).expect("equivalent cutoff"); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00+00:00"); +} + #[test] fn compact_oversized_artifact_counts_fail_closed() { let assignment_count = MAX_EVIDENCE_UNITS as u64 + 1; From 53a15974562057caff6445d36847693914ffcece Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:13:08 +0900 Subject: [PATCH 30/75] fix(analysis): normalize episode cutoff and validation status --- .../src/episode_membership_artifact.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index f72e6812c..e74795172 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -206,7 +206,9 @@ pub fn execute_episode_membership_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION || request.output_profile != EPISODE_MEMBERSHIP_OUTPUT_PROFILE { @@ -241,12 +243,7 @@ pub fn execute_episode_membership_run( inference_status: EPISODE_MEMBERSHIP_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "episode_membership", - assignment_count, - 4, - EPISODE_MEMBERSHIP_INFERENCE_STATUS, - )?; + let summary = AnalysisResultSummary::new("episode_membership", assignment_count, 4, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, From dae93fa18fb2e6b63734840a0fc1bca08a35df9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:13:57 +0900 Subject: [PATCH 31/75] test(analysis): reproduce target cutoff and validation drift --- .../membership_target_execution_contract.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 2f79fd7ba..c255ef4b6 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -91,6 +91,15 @@ fn mixed_target_kinds_emit_digest_bound_refusals_without_recovery_metric() { execution.terminal_result.run_state, AnalysisRunTerminalState::Succeeded ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); assert_eq!( execution.terminal_result.result_sha256.as_deref(), Some(execution.artifact.sha256().expect("digest").as_str()) @@ -101,6 +110,14 @@ fn mixed_target_kinds_emit_digest_bound_refusals_without_recovery_metric() { ); } +#[test] +fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { + let mut offset_request = request(); + offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); + let execution = execute(&offset_request, &mixed_documents()).expect("equivalent cutoff"); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00+00:00"); +} + #[test] fn compact_oversized_artifact_counts_fail_closed() { let document_count = MAX_EVIDENCE_UNITS as u64 + 1; From 10b61fcd4e0e421ce76fd3d5553ac6e2038ca219 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 17:14:58 +0900 Subject: [PATCH 32/75] fix(analysis): normalize target cutoff and validation status --- .../analysis_engine/src/membership_target_artifact.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 8d9981d09..6ca2c45cf 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -206,7 +206,9 @@ pub fn execute_membership_target_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION || request.output_profile != MEMBERSHIP_TARGET_OUTPUT_PROFILE { @@ -319,12 +321,7 @@ pub fn execute_membership_target_run( 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 summary = AnalysisResultSummary::new("membership_target", document_count, 4, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, From ffee655404716bf8d33c898a3c1a87a543abe701 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:44:59 +0900 Subject: [PATCH 33/75] test(analysis): expose future identity leakage in base execution --- .../tests/end_to_end_contract.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/analysis_engine/tests/end_to_end_contract.rs b/crates/analysis_engine/tests/end_to_end_contract.rs index e1a01258c..f17679800 100644 --- a/crates/analysis_engine/tests/end_to_end_contract.rs +++ b/crates/analysis_engine/tests/end_to_end_contract.rs @@ -53,6 +53,36 @@ fn production_shape_run_excludes_future_available_evidence() { ); } +#[test] +fn future_duplicate_identity_cannot_change_a_historical_cutoff_result() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "future-duplicate-run".into(), + tenant_workspace_id: "workspace-opaque-1".into(), + snapshot_id: "snapshot-future-duplicate".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-evidence-v1".into(), + output_profile: "validation-report".into(), + }; + let accepted = + AnalysisRunAccepted::new("run-future-duplicate", "accepted", "future-duplicate-run") + .expect("accepted"); + let corpus = AnalysisCorpus::new( + "snapshot-future-duplicate", + vec![ + evidence("stable-id", "2026-07-31T23:59:59Z", 2), + evidence("stable-id", "2026-08-01T00:00:01Z", 99), + ], + ) + .expect("snapshot"); + + let execution = execute_analysis_run(&request, &accepted, &corpus, "2026-08-01T00:01:00Z") + .expect("future-unavailable evidence must not affect the historical run"); + let artifact = execution.artifact.expect("artifact"); + assert_eq!(artifact.eligible_evidence_count, 1); + assert_eq!(artifact.eligible_membership_count, 2); +} + #[test] fn evidence_available_exactly_at_cutoff_is_eligible_and_keeps_membership() { let request = AnalysisRunRequest { From 0b7155cc238defb1e55129ff3000658f04b343cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:49:04 +0900 Subject: [PATCH 34/75] fix(analysis): enforce cutoff before identity admission --- crates/analysis_engine/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 45b66ee1b..b784c786c 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -374,12 +374,13 @@ pub fn execute_analysis_run( let mut identities = BTreeSet::new(); let mut eligible = Vec::new(); for unit in &corpus.evidence_units { + if unit.available_time.instant() > cutoff.instant() { + continue; + } if !identities.insert(unit.evidence_id.clone()) { return Err(AnalysisEngineError::DuplicateEvidence); } - if unit.available_time.instant() <= cutoff.instant() { - eligible.push(unit); - } + eligible.push(unit); } let completed_at = completed_at.into(); @@ -837,7 +838,6 @@ mod tests { execute_analysis_run(&request(), &accepted(), &corpus, "not-a-time"), Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) ); - let no_evidence = AnalysisCorpus::new( "snapshot-1", vec![unit( From aecb7d8e658a5b9c98cda173bed0b77cc1464451 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:14:52 +0900 Subject: [PATCH 35/75] fix(analysis): canonicalize episode cutoff fixture --- .../tests/episode_membership_execution_contract.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs index b25dff2e1..11ae51b55 100644 --- a/crates/analysis_engine/tests/episode_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -128,7 +128,10 @@ fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { let mut offset_request = request(); offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); let execution = execute(&offset_request, &mixed_assignments()).expect("equivalent cutoff"); - assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00+00:00"); + assert_eq!( + execution.artifact.knowledge_cutoff, + "2026-08-01T00:00:00Z" + ); } #[test] From 2baf45c7ab2a2ab1b1144a6981e8dd59f8955478 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:16:30 +0900 Subject: [PATCH 36/75] style(analysis): format episode membership profile --- crates/analysis_engine/src/episode_membership_artifact.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index e74795172..3aa7c136f 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -243,7 +243,8 @@ pub fn execute_episode_membership_run( inference_status: EPISODE_MEMBERSHIP_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new("episode_membership", assignment_count, 4, "validated")?; + let summary = + AnalysisResultSummary::new("episode_membership", assignment_count, 4, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, From e71b750cc79d0c821af648455f341a09f8a964ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:17:54 +0900 Subject: [PATCH 37/75] fix(analysis): canonicalize membership-target cutoff fixture --- .../tests/membership_target_execution_contract.rs | 12 ++++++++---- 1 file changed, 8 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 c255ef4b6..8120ae3f0 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, MembershipTargetArtifact, - MembershipTargetDocument, MAX_EVIDENCE_UNITS, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + AnalysisEngineError, MAX_EVIDENCE_UNITS, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, MEMBERSHIP_TARGET_OUTPUT_PROFILE, + MembershipTargetArtifact, MembershipTargetDocument, execute_membership_target_run, }; use membership_target::MembershipTargetKind; use temporal_core::KnowledgeCutoff; @@ -115,7 +115,10 @@ fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { let mut offset_request = request(); offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); let execution = execute(&offset_request, &mixed_documents()).expect("equivalent cutoff"); - assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00+00:00"); + assert_eq!( + execution.artifact.knowledge_cutoff, + "2026-08-01T00:00:00Z" + ); } #[test] @@ -137,7 +140,8 @@ fn compact_oversized_artifact_counts_fail_closed() { 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(), + 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!( From a7415059961394eb429afd5f46cecab1b8139d9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:19:09 +0900 Subject: [PATCH 38/75] style(analysis): format copy-identity bound contract --- .../tests/copy_identity_artifact_bound_contract.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs index c6900be34..2f334df06 100644 --- a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs @@ -1,9 +1,9 @@ //! Execution and serialization bounds for the copy-identity profile. use analysis_engine::{ - AnalysisEngineError, CopyIdentityArtifact, CopyIdentityDocument, MAX_EVIDENCE_UNITS, - COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, COPY_IDENTITY_MODEL_CONTRACT_VERSION, - COPY_IDENTITY_OUTPUT_PROFILE, execute_copy_identity_run, + AnalysisEngineError, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + COPY_IDENTITY_MODEL_CONTRACT_VERSION, COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, + CopyIdentityDocument, MAX_EVIDENCE_UNITS, execute_copy_identity_run, }; use copy_identity::CopyKind; use temporal_core::{AvailableTime, KnowledgeCutoff}; From f1b794df222dc2398f4e15a3df1eb694f115eb9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:19:39 +0900 Subject: [PATCH 39/75] style(analysis): format location-membership bound contract --- .../tests/location_membership_artifact_bound_contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs b/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs index 80d0ed06d..9869b4284 100644 --- a/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs +++ b/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs @@ -1,8 +1,8 @@ //! Serialization bound for the location-membership analysis artifact. use analysis_engine::{ - AnalysisEngineError, LocationMembershipArtifact, MAX_EVIDENCE_UNITS, - LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + AnalysisEngineError, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, LocationMembershipArtifact, + MAX_EVIDENCE_UNITS, }; #[test] From aa730c63563eb4a33048d822b581036c8487bd47 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:20:12 +0900 Subject: [PATCH 40/75] style(analysis): format subevent regression contract --- .../tests/subevent_containment_regression_contract.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/tests/subevent_containment_regression_contract.rs b/crates/analysis_engine/tests/subevent_containment_regression_contract.rs index 032636793..9ef846830 100644 --- a/crates/analysis_engine/tests/subevent_containment_regression_contract.rs +++ b/crates/analysis_engine/tests/subevent_containment_regression_contract.rs @@ -29,8 +29,12 @@ fn request(cutoff: &str) -> AnalysisRunRequest { } fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { - AnalysisRunAccepted::new("run-subevent-containment", "accepted", &request.idempotency_key) - .expect("accepted") + AnalysisRunAccepted::new( + "run-subevent-containment", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") } fn assignments() -> Vec { From 389f7487eaa03f3a8d8f298136820af078fc9eb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 21:16:54 +0900 Subject: [PATCH 41/75] style(analysis_engine): apply rustfmt after main restack Co-Authored-By: Claude Fable 5.1 --- .../analysis_engine/src/membership_target_artifact.rs | 10 +++++----- .../src/subevent_containment_artifact.rs | 8 ++------ .../tests/episode_membership_execution_contract.rs | 5 +---- .../tests/membership_target_execution_contract.rs | 5 +---- 4 files changed, 9 insertions(+), 19 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 6ca2c45cf..9b0b18c46 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -1,6 +1,6 @@ //! Digest-bound membership-target refusals as an analysis-run profile. -use membership_target::{refuse_collapsed_target, MembershipTargetError, MembershipTargetKind}; +use membership_target::{MembershipTargetError, MembershipTargetKind, refuse_collapsed_target}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use temporal_core::KnowledgeCutoff; @@ -9,8 +9,8 @@ use tepp_api::{ }; use crate::{ - format_digest, require_receipt_identity, valid_identifier, AnalysisEngineError, - MAX_EVIDENCE_UNITS, + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, }; /// Versioned schema for a completed membership-target artifact. @@ -354,8 +354,8 @@ fn map_membership_target_error(error: MembershipTargetError) -> AnalysisEngineEr #[cfg(test)] mod tests { use super::{ - MembershipTargetArtifact, MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, - MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MEMBERSHIP_TARGET_INFERENCE_STATUS, + MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + MEMBERSHIP_TARGET_INFERENCE_STATUS, MembershipTargetArtifact, }; use crate::AnalysisEngineError; diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs index 427d6442c..f6966333d 100644 --- a/crates/analysis_engine/src/subevent_containment_artifact.rs +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -243,12 +243,8 @@ pub fn execute_subevent_containment_run( inference_status: SUBEVENT_CONTAINMENT_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "subevent_containment", - assignment_count, - 4, - "validated", - )?; + let summary = + AnalysisResultSummary::new("subevent_containment", assignment_count, 4, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs index 11ae51b55..a87473881 100644 --- a/crates/analysis_engine/tests/episode_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -128,10 +128,7 @@ fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { let mut offset_request = request(); offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); let execution = execute(&offset_request, &mixed_assignments()).expect("equivalent cutoff"); - assert_eq!( - execution.artifact.knowledge_cutoff, - "2026-08-01T00:00:00Z" - ); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00Z"); } #[test] diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 8120ae3f0..2ca33cdf8 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -115,10 +115,7 @@ fn equivalent_rfc3339_cutoff_offsets_are_the_same_instant() { let mut offset_request = request(); offset_request.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); let execution = execute(&offset_request, &mixed_documents()).expect("equivalent cutoff"); - assert_eq!( - execution.artifact.knowledge_cutoff, - "2026-08-01T00:00:00Z" - ); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00Z"); } #[test] From ad3c020a2a43e224d8c3c794e942c9dfe0094501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 22:46:06 +0900 Subject: [PATCH 42/75] test(analysis_engine): cover terminal-result construction failure on four profiles An invalid completed_at timestamp is the only input that makes AnalysisRunTerminalResult::succeeded fail after execute_* validation, so each profile now asserts Err(Api(InvalidWirePayload)) for it. This covers the previously unexercised '?' on terminal-result construction in the episode-membership, subevent-containment, membership-target and inferred-status executions. Co-Authored-By: Claude Fable 5.1 --- .../episode_membership_execution_contract.rs | 18 +++++++++++++++++- .../inferred_status_execution_contract.rs | 18 +++++++++++++++++- .../membership_target_execution_contract.rs | 18 +++++++++++++++++- .../subevent_containment_execution_contract.rs | 18 +++++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/tests/episode_membership_execution_contract.rs b/crates/analysis_engine/tests/episode_membership_execution_contract.rs index a87473881..c219a095f 100644 --- a/crates/analysis_engine/tests/episode_membership_execution_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -8,7 +8,7 @@ use analysis_engine::{ }; use episode_membership::EventWindow; use temporal_core::{AvailableTime, KnowledgeCutoff}; -use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") @@ -294,3 +294,19 @@ fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn invalid_completed_at_fails_terminal_result_construction() { + let request = request(); + assert_eq!( + execute_episode_membership_run( + &request, + &accepted(&request), + "snapshot-episode-membership", + cutoff(), + &mixed_assignments(), + "not-a-timestamp", + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); +} diff --git a/crates/analysis_engine/tests/inferred_status_execution_contract.rs b/crates/analysis_engine/tests/inferred_status_execution_contract.rs index 4247bddbc..b8905f44e 100644 --- a/crates/analysis_engine/tests/inferred_status_execution_contract.rs +++ b/crates/analysis_engine/tests/inferred_status_execution_contract.rs @@ -7,7 +7,7 @@ use analysis_engine::{ }; use inferred_status::EvidenceStatus; use temporal_core::{AvailableTime, KnowledgeCutoff}; -use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") @@ -244,3 +244,19 @@ fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn invalid_completed_at_fails_terminal_result_construction() { + let request = request(); + assert_eq!( + execute_inferred_status_run( + &request, + &accepted(&request), + "snapshot-inferred-status", + cutoff(), + &mixed_evidence(), + "not-a-timestamp", + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); +} diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 2ca33cdf8..860d9e1a2 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -7,7 +7,7 @@ use analysis_engine::{ }; use membership_target::MembershipTargetKind; use temporal_core::KnowledgeCutoff; -use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") @@ -290,3 +290,19 @@ fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn invalid_completed_at_fails_terminal_result_construction() { + let request = request(); + assert_eq!( + execute_membership_target_run( + &request, + &accepted(&request), + "snapshot-membership-target", + cutoff(), + &mixed_documents(), + "not-a-timestamp", + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); +} diff --git a/crates/analysis_engine/tests/subevent_containment_execution_contract.rs b/crates/analysis_engine/tests/subevent_containment_execution_contract.rs index 98dbff773..9cc786f15 100644 --- a/crates/analysis_engine/tests/subevent_containment_execution_contract.rs +++ b/crates/analysis_engine/tests/subevent_containment_execution_contract.rs @@ -7,7 +7,7 @@ use analysis_engine::{ }; use subevent_containment::EventInterval; use temporal_core::{AvailableTime, KnowledgeCutoff}; -use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") @@ -278,3 +278,19 @@ fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn invalid_completed_at_fails_terminal_result_construction() { + let request = request(); + assert_eq!( + execute_subevent_containment_run( + &request, + &accepted(&request), + "snapshot-subevent-containment", + cutoff(), + &mixed_assignments(), + "not-a-timestamp", + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); +} From 0655f42d222a868d99d4c99ebeaa6ca1386e3c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 23:06:12 +0900 Subject: [PATCH 43/75] test(psychometric_core): cover manifest variance error messages --- ...rdised_manifest_variance_error_messages.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs diff --git a/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs b/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs new file mode 100644 index 000000000..9edfc7dc7 --- /dev/null +++ b/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs @@ -0,0 +1,23 @@ +use psychometric_core::PsychometricError; + +#[test] +fn standardised_manifest_variance_boundary_messages_are_stable() { + assert_eq!( + PsychometricError::StandardisedManifestVarianceRequiresPositiveManifestVariance.to_string(), + "standardised measurement-error variance requires strictly positive measurement-error variance" + ); + assert_eq!( + PsychometricError::UnstandardisedManifestVarianceIsNotStandardisedManifestVariance + .to_string(), + "unstandardised measurement-error variance is not standardised measurement-error variance" + ); + assert_eq!( + PsychometricError::StandardisedManifestTraitVarianceIsNotStandardisedManifestVariance + .to_string(), + "standardised manifest-trait variance is not standardised measurement-error variance" + ); + assert_eq!( + PsychometricError::ObservedVarianceIsNotStandardisedManifestVariance.to_string(), + "observed-indicator variance is not standardised measurement-error variance" + ); +} From ecf8f58899052131406cde7c54f517b45f9a7dc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 23:11:22 +0900 Subject: [PATCH 44/75] fix(psychometric_core): document manifest variance contract test --- .../tests/standardised_manifest_variance_error_messages.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs b/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs index 9edfc7dc7..8809d522d 100644 --- a/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs +++ b/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs @@ -1,3 +1,5 @@ +//! Contract tests for standardised manifest-variance boundary error messages. + use psychometric_core::PsychometricError; #[test] From 12bbcfbce7e3248f1237c601e99c7cf9426ffa57 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 00:09:57 +0900 Subject: [PATCH 45/75] fix(analysis_engine): make inferred-status guards testable --- .../src/inferred_status_artifact.rs | 103 +++++++++++++----- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/crates/analysis_engine/src/inferred_status_artifact.rs b/crates/analysis_engine/src/inferred_status_artifact.rs index 5b8cc1de2..89d8647f2 100644 --- a/crates/analysis_engine/src/inferred_status_artifact.rs +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -122,15 +122,10 @@ impl InferredStatusArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > INFERRED_STATUS_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. @@ -280,18 +275,16 @@ fn census_evidence( observed_count = increment(observed_count)?; } EvidenceStatus::Inferred => { - match refuse_inferred_as_observed(row.status()) { - Err(InferredStatusError::InferredIsNotObserved) => { - refused_as_observed_count = increment(refused_as_observed_count)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } - match refuse_inferred_as_transition(row.status()) { - Err(InferredStatusError::InferredIsNotTransition) => { - refused_as_transition_count = increment(refused_as_transition_count)?; - } - Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), - } + require_refusal( + refuse_inferred_as_observed(row.status()), + InferredStatusError::InferredIsNotObserved, + )?; + refused_as_observed_count = increment(refused_as_observed_count)?; + require_refusal( + refuse_inferred_as_transition(row.status()), + InferredStatusError::InferredIsNotTransition, + )?; + refused_as_transition_count = increment(refused_as_transition_count)?; inferred_count = increment(inferred_count)?; } } @@ -310,22 +303,30 @@ fn increment(count: u64) -> Result { .ok_or(AnalysisEngineError::ArithmeticOverflow) } -fn map_inferred_status_error(error: InferredStatusError) -> AnalysisEngineError { - match error { - InferredStatusError::InferredIsNotObserved - | InferredStatusError::InferredIsNotTransition - | InferredStatusError::InvalidStatusPayload - | _ => AnalysisEngineError::InvalidEvidence, +fn require_refusal( + result: Result<(), InferredStatusError>, + expected: InferredStatusError, +) -> Result<(), AnalysisEngineError> { + match result { + Err(error) if error == expected => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), } } +fn map_inferred_status_error(_: InferredStatusError) -> AnalysisEngineError { + AnalysisEngineError::InvalidEvidence +} + #[cfg(test)] mod tests { + use inferred_status::InferredStatusError; + use super::{ INFERRED_STATUS_ARTIFACT_BYTE_LIMIT, INFERRED_STATUS_ARTIFACT_SCHEMA_VERSION, - INFERRED_STATUS_INFERENCE_STATUS, InferredStatusArtifact, + INFERRED_STATUS_INFERENCE_STATUS, InferredStatusArtifact, map_inferred_status_error, + require_refusal, }; - use crate::{AnalysisEngineError, MAX_EVIDENCE_UNITS}; + use crate::{AnalysisEngineError, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS}; fn artifact() -> InferredStatusArtifact { InferredStatusArtifact { @@ -368,6 +369,54 @@ mod tests { ); } + #[test] + fn maximal_valid_artifact_serialization_stays_within_wire_limit() { + let mut maximal = artifact(); + maximal.run_id = "r".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES); + maximal.snapshot_id = "s".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES); + maximal.evidence_count = MAX_EVIDENCE_UNITS as u64; + maximal.observed_count = 1; + maximal.inferred_count = MAX_EVIDENCE_UNITS as u64 - 1; + maximal.refused_as_observed_count = maximal.inferred_count; + maximal.refused_as_transition_count = maximal.inferred_count; + + let unchecked_payload = serde_json::to_string(&maximal).expect("unchecked fixture json"); + assert!(unchecked_payload.len() < INFERRED_STATUS_ARTIFACT_BYTE_LIMIT); + assert_eq!(maximal.to_json(), Ok(unchecked_payload)); + } + + #[test] + fn refusal_contract_guard_fails_closed_on_domain_result_drift() { + assert_eq!( + require_refusal( + Err(InferredStatusError::InferredIsNotObserved), + InferredStatusError::InferredIsNotObserved, + ), + Ok(()) + ); + assert_eq!( + require_refusal(Ok(()), InferredStatusError::InferredIsNotObserved), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_refusal( + Err(InferredStatusError::InvalidStatusPayload), + InferredStatusError::InferredIsNotObserved, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + for error in [ + InferredStatusError::InferredIsNotObserved, + InferredStatusError::InferredIsNotTransition, + InferredStatusError::InvalidStatusPayload, + ] { + assert_eq!( + map_inferred_status_error(error), + AnalysisEngineError::InvalidEvidence + ); + } + } + #[test] fn artifact_claimed_count_above_execution_limit_fails_parse_and_serialize() { let mut oversized = artifact(); From 294f091cfb353e90267932da67289a65afc17cda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:02:17 +0900 Subject: [PATCH 46/75] fix(analysis): enforce location refusal contract --- .../src/location_membership_artifact.rs | 79 ++++++++++++++++--- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index cf404871e..61164e171 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -1,7 +1,8 @@ //! Digest-bound location-membership refusals as an analysis-run profile. use location_membership::{ - LocationKind, refuse_location_as_entity_identity, refuse_location_as_language_channel, + LocationKind, LocationMembershipError, refuse_location_as_entity_identity, + refuse_location_as_language_channel, }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -21,7 +22,7 @@ pub const LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION: &str = "tepp.location_mem pub const LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION: &str = "location_membership_v1"; /// Analysis-run output profile required for a location-membership artifact. pub const LOCATION_MEMBERSHIP_OUTPUT_PROFILE: &str = "location_membership_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted artifact JSON input size. pub const LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const LOCATION_MEMBERSHIP_INFERENCE_STATUS: &str = "location_is_not_entity_identity_not_language_channel"; @@ -126,15 +127,10 @@ impl LocationMembershipArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. @@ -235,8 +231,14 @@ pub fn execute_location_membership_run( } match document.kind() { LocationKind::Location => { - let _ = refuse_location_as_entity_identity(document.kind()); - let _ = refuse_location_as_language_channel(document.kind()); + require_refusal( + refuse_location_as_entity_identity(document.kind()), + LocationMembershipError::LocationIsNotEntityIdentity, + )?; + require_refusal( + refuse_location_as_language_channel(document.kind()), + LocationMembershipError::LocationIsNotLanguageChannel, + )?; refused_as_entity_identity_count += 1; refused_as_language_channel_count += 1; location_count += 1; @@ -295,13 +297,25 @@ pub fn execute_location_membership_run( }) } +fn require_refusal( + result: Result<(), LocationMembershipError>, + expected: LocationMembershipError, +) -> Result<(), AnalysisEngineError> { + match result { + Err(error) if error == expected => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + #[cfg(test)] mod tests { + use location_membership::LocationMembershipError; + use super::{ LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, - LOCATION_MEMBERSHIP_INFERENCE_STATUS, LocationMembershipArtifact, + LOCATION_MEMBERSHIP_INFERENCE_STATUS, LocationMembershipArtifact, require_refusal, }; - use crate::AnalysisEngineError; + use crate::{AnalysisEngineError, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS}; fn artifact() -> LocationMembershipArtifact { LocationMembershipArtifact { @@ -347,6 +361,45 @@ mod tests { ); } + #[test] + fn maximal_valid_artifact_serialization_stays_within_wire_limit() { + let mut maximal = artifact(); + maximal.run_id = "r".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES); + maximal.snapshot_id = "s".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES); + maximal.document_count = MAX_EVIDENCE_UNITS as u64; + maximal.location_count = MAX_EVIDENCE_UNITS as u64 - 1; + maximal.entity_identity_count = 1; + maximal.language_channel_count = 0; + maximal.refused_as_entity_identity_count = maximal.location_count; + maximal.refused_as_language_channel_count = maximal.location_count; + + let unchecked_payload = serde_json::to_string(&maximal).expect("unchecked fixture json"); + assert!(unchecked_payload.len() < LOCATION_MEMBERSHIP_ARTIFACT_BYTE_LIMIT); + assert_eq!(maximal.to_json(), Ok(unchecked_payload)); + } + + #[test] + fn refusal_contract_guard_fails_closed_on_domain_result_drift() { + assert_eq!( + require_refusal( + Err(LocationMembershipError::LocationIsNotEntityIdentity), + LocationMembershipError::LocationIsNotEntityIdentity, + ), + Ok(()) + ); + assert_eq!( + require_refusal(Ok(()), LocationMembershipError::LocationIsNotEntityIdentity), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_refusal( + Err(LocationMembershipError::LocationIsNotLanguageChannel), + LocationMembershipError::LocationIsNotEntityIdentity, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + #[test] fn artifact_metadata_tampering_fails_closed() { let artifact = artifact(); From b4a135ed0149b7020383f071edda6db9f5d3ff60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:09:56 +0900 Subject: [PATCH 47/75] test(analysis): pin episode cutoff before duplicate admission --- ...de_membership_cutoff_duplicate_contract.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs diff --git a/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs new file mode 100644 index 000000000..2141bd3f6 --- /dev/null +++ b/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs @@ -0,0 +1,93 @@ +//! Regression contract for cutoff-safe episode-membership duplicate admission. + +use analysis_engine::{ + EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION, EPISODE_MEMBERSHIP_OUTPUT_PROFILE, + EpisodeMembershipAssignment, execute_episode_membership_run, +}; +use episode_membership::EventWindow; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn window(start: i64, end: i64) -> EventWindow { + EventWindow::new(start, end).expect("valid event window") +} + +fn assignment( + assignment_id: &str, + membership: EventWindow, + episode: EventWindow, + available_at: &str, +) -> EpisodeMembershipAssignment { + EpisodeMembershipAssignment::new( + assignment_id, + membership, + episode, + AvailableTime::parse_rfc3339(available_at).expect("valid availability time"), + ) + .expect("valid assignment") +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_cutoff_result() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "episode-membership-cutoff-duplicate".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-episode-membership-cutoff-duplicate".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION.into(), + output_profile: EPISODE_MEMBERSHIP_OUTPUT_PROFILE.into(), + }; + let accepted = AnalysisRunAccepted::new( + "run-episode-membership-cutoff-duplicate", + "accepted", + &request.idempotency_key, + ) + .expect("accepted receipt"); + let cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff).expect("cutoff"); + let episode = window(10, 20); + + let visible = vec![ + assignment( + "contained-a", + window(11, 19), + episode, + "2026-07-01T00:00:00Z", + ), + assignment( + "escaped-b", + window(9, 15), + episode, + "2026-07-02T00:00:00Z", + ), + ]; + let baseline = execute_episode_membership_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &visible, + "2026-08-02T00:00:00Z", + ) + .expect("historical baseline"); + + let mut with_future_duplicate = vec![assignment( + "contained-a", + window(12, 13), + episode, + "2026-08-02T00:00:00Z", + )]; + with_future_duplicate.extend(visible); + let replay = execute_episode_membership_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &with_future_duplicate, + "2026-08-02T00:00:00Z", + ) + .expect("future-unavailable duplicate must not enter cutoff admission"); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} From cb5d97cb159958ef259b2f2d97968fdd7830a1e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:10:31 +0900 Subject: [PATCH 48/75] fix(analysis): exclude future episode evidence before identity admission --- crates/analysis_engine/src/episode_membership_artifact.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index 3aa7c136f..3fa58dd09 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -269,12 +269,12 @@ fn census_assignments( let mut escaped_count = 0_u64; let mut refused_as_escape_count = 0_u64; for assignment in assignments { - if !seen.insert(assignment.assignment_id()) { - return Err(AnalysisEngineError::DuplicateEvidence); - } if assignment.available_time().instant() > knowledge_cutoff.instant() { continue; } + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } match refuse_membership_outside_episode(assignment.membership(), assignment.episode()) { Ok(()) => { contained_count = increment(contained_count)?; From 21d85de0793c1a386d693aefc42399d5d558d584 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:11:59 +0900 Subject: [PATCH 49/75] test(analysis): pin subevent cutoff before duplicate admission --- ...t_containment_cutoff_duplicate_contract.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs diff --git a/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs new file mode 100644 index 000000000..1f573173b --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs @@ -0,0 +1,93 @@ +//! Regression contract for cutoff-safe subevent-containment duplicate admission. + +use analysis_engine::{ + SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION, SUBEVENT_CONTAINMENT_OUTPUT_PROFILE, + SubeventContainmentAssignment, execute_subevent_containment_run, +}; +use subevent_containment::EventInterval; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn interval(start: i64, end: i64) -> EventInterval { + EventInterval::new(start, end).expect("valid event interval") +} + +fn assignment( + assignment_id: &str, + parent: EventInterval, + child: EventInterval, + available_at: &str, +) -> SubeventContainmentAssignment { + SubeventContainmentAssignment::new( + assignment_id, + parent, + child, + AvailableTime::parse_rfc3339(available_at).expect("valid availability time"), + ) + .expect("valid assignment") +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_cutoff_result() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "subevent-containment-cutoff-duplicate".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-subevent-containment-cutoff-duplicate".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION.into(), + output_profile: SUBEVENT_CONTAINMENT_OUTPUT_PROFILE.into(), + }; + let accepted = AnalysisRunAccepted::new( + "run-subevent-containment-cutoff-duplicate", + "accepted", + &request.idempotency_key, + ) + .expect("accepted receipt"); + let cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff).expect("cutoff"); + let parent = interval(10, 40); + + let visible = vec![ + assignment( + "contained-a", + parent, + interval(15, 30), + "2026-07-01T00:00:00Z", + ), + assignment( + "escaped-b", + parent, + interval(0, 20), + "2026-07-02T00:00:00Z", + ), + ]; + let baseline = execute_subevent_containment_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &visible, + "2026-08-02T00:00:00Z", + ) + .expect("historical baseline"); + + let mut with_future_duplicate = vec![assignment( + "contained-a", + parent, + interval(16, 17), + "2026-08-02T00:00:00Z", + )]; + with_future_duplicate.extend(visible); + let replay = execute_subevent_containment_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &with_future_duplicate, + "2026-08-02T00:00:00Z", + ) + .expect("future-unavailable duplicate must not enter cutoff admission"); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} From 315de53a0f03de9d476ea578e47b43798a3a4deb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:12:36 +0900 Subject: [PATCH 50/75] fix(analysis): exclude future subevent evidence before identity admission --- crates/analysis_engine/src/subevent_containment_artifact.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs index f6966333d..c9c301209 100644 --- a/crates/analysis_engine/src/subevent_containment_artifact.rs +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -269,12 +269,12 @@ fn census_assignments( let mut escaped_count = 0_u64; let mut refused_as_escape_count = 0_u64; for assignment in assignments { - if !seen.insert(assignment.assignment_id()) { - return Err(AnalysisEngineError::DuplicateEvidence); - } if assignment.available_time().instant() > knowledge_cutoff.instant() { continue; } + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } match refuse_escaped_subevent(assignment.parent(), assignment.child()) { Ok(()) => { contained_count = increment(contained_count)?; From 66951df7d6c704e03edc7fd9b41a1857788034ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:13:12 +0900 Subject: [PATCH 51/75] test(analysis): require membership-target availability cutoff --- .../membership_target_cutoff_contract.rs | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 crates/analysis_engine/tests/membership_target_cutoff_contract.rs diff --git a/crates/analysis_engine/tests/membership_target_cutoff_contract.rs b/crates/analysis_engine/tests/membership_target_cutoff_contract.rs new file mode 100644 index 000000000..1cd1fab58 --- /dev/null +++ b/crates/analysis_engine/tests/membership_target_cutoff_contract.rs @@ -0,0 +1,83 @@ +//! Regression contract for leakage-safe membership-target admission. + +use analysis_engine::{ + MEMBERSHIP_TARGET_MODEL_CONTRACT_VERSION, MEMBERSHIP_TARGET_OUTPUT_PROFILE, + MembershipTargetDocument, execute_membership_target_run, +}; +use membership_target::MembershipTargetKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn document( + document_id: &str, + kind: MembershipTargetKind, + available_at: &str, +) -> MembershipTargetDocument { + MembershipTargetDocument::new( + document_id, + kind, + AvailableTime::parse_rfc3339(available_at).expect("valid availability time"), + ) + .expect("valid membership-target document") +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_cutoff_result() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "membership-target-cutoff-duplicate".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-membership-target-cutoff-duplicate".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(), + }; + let accepted = AnalysisRunAccepted::new( + "run-membership-target-cutoff-duplicate", + "accepted", + &request.idempotency_key, + ) + .expect("accepted receipt"); + let cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff).expect("cutoff"); + + let visible = vec![ + document( + "lang-a", + MembershipTargetKind::Language, + "2026-07-01T00:00:00Z", + ), + document( + "entity-b", + MembershipTargetKind::Entity, + "2026-07-02T00:00:00Z", + ), + ]; + let baseline = execute_membership_target_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &visible, + "2026-08-02T00:00:00Z", + ) + .expect("historical baseline"); + + let mut with_future_duplicate = vec![document( + "lang-a", + MembershipTargetKind::Project, + "2026-08-02T00:00:00Z", + )]; + with_future_duplicate.extend(visible); + let replay = execute_membership_target_run( + &request, + &accepted, + &request.snapshot_id, + cutoff, + &with_future_duplicate, + "2026-08-02T00:00:00Z", + ) + .expect("future-unavailable duplicate must not enter cutoff admission"); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} From 5f3bd54f6aaf6bf01d95953f5ff5f74decd019df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:14:17 +0900 Subject: [PATCH 52/75] fix(analysis): make membership-target admission cutoff aware --- .../src/membership_target_artifact.rs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 9b0b18c46..52a963e6f 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -3,7 +3,7 @@ use membership_target::{MembershipTargetError, MembershipTargetKind, refuse_collapsed_target}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; @@ -29,6 +29,7 @@ const MEMBERSHIP_TARGET_INFERENCE_STATUS: &str = pub struct MembershipTargetDocument { document_id: String, kind: MembershipTargetKind, + available_time: AvailableTime, } impl MembershipTargetDocument { @@ -41,12 +42,17 @@ impl MembershipTargetDocument { pub fn new( document_id: impl Into, kind: MembershipTargetKind, + available_time: AvailableTime, ) -> Result { let document_id = document_id.into(); if !valid_identifier(&document_id) { return Err(AnalysisEngineError::InvalidEvidence); } - Ok(Self { document_id, kind }) + Ok(Self { + document_id, + kind, + available_time, + }) } /// Return the opaque document identity. @@ -60,6 +66,12 @@ impl MembershipTargetDocument { pub const fn kind(&self) -> MembershipTargetKind { self.kind } + + /// Return when this document became available for historical analysis. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } } /// Completed, bounded membership-target census for analysis-run clients. @@ -219,6 +231,7 @@ pub fn execute_membership_target_run( } let mut seen = std::collections::BTreeSet::new(); + let mut document_count = 0_u64; let mut language_count = 0_u64; let mut episode_count = 0_u64; let mut template_count = 0_u64; @@ -229,9 +242,13 @@ pub fn execute_membership_target_run( let mut refused_as_entity_count = 0_u64; let mut refused_as_project_count = 0_u64; for document in documents { + if document.available_time().instant() > knowledge_cutoff.instant() { + continue; + } if !seen.insert(document.document_id()) { return Err(AnalysisEngineError::DuplicateEvidence); } + document_count = increment(document_count)?; match document.kind() { MembershipTargetKind::Language | MembershipTargetKind::Episode @@ -288,8 +305,6 @@ pub fn execute_membership_target_run( } } - 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)) From 93be3e043ddd21625d0b4aa67ba046bce8ef7621 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:14:55 +0900 Subject: [PATCH 53/75] test(analysis): construct membership targets with availability --- .../membership_target_execution_contract.rs | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 860d9e1a2..2d86c55b2 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -6,13 +6,29 @@ use analysis_engine::{ MembershipTargetArtifact, MembershipTargetDocument, execute_membership_target_run, }; use membership_target::MembershipTargetKind; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn document( + document_id: impl Into, + kind: MembershipTargetKind, +) -> MembershipTargetDocument { + MembershipTargetDocument::new( + document_id, + kind, + available("2026-07-01T00:00:00Z"), + ) + .expect("document") +} + fn request() -> AnalysisRunRequest { AnalysisRunRequest { contract_version: 1, @@ -36,15 +52,13 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { 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"), + document("lang-a", MembershipTargetKind::Language), + document("ep-b", MembershipTargetKind::Episode), + document("tmpl-c", MembershipTargetKind::Template), + document("dept-d", MembershipTargetKind::Department), + document("pool-e", MembershipTargetKind::OpportunityPool), + document("ent-f", MembershipTargetKind::Entity), + document("proj-g", MembershipTargetKind::Project), ] } @@ -159,55 +173,59 @@ fn empty_single_class_and_duplicate_identities_fail_closed() { Err(AnalysisEngineError::InvalidEvidence) ); let language_only = vec![ - MembershipTargetDocument::new("lang-a", MembershipTargetKind::Language).expect("language"), - MembershipTargetDocument::new("lang-b", MembershipTargetKind::Language).expect("language"), + document("lang-a", MembershipTargetKind::Language), + document("lang-b", MembershipTargetKind::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"), + document("ent-a", MembershipTargetKind::Entity), + document("ent-b", MembershipTargetKind::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"), + document("proj-a", MembershipTargetKind::Project), + document("proj-b", MembershipTargetKind::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"), + document("lang-a", MembershipTargetKind::Language), + document("ep-b", MembershipTargetKind::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"), + document("ent-a", MembershipTargetKind::Entity), + document("proj-b", MembershipTargetKind::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"), + document("same", MembershipTargetKind::Language), + document("same", MembershipTargetKind::Entity), ]; assert_eq!( execute(&request, &duplicates), Err(AnalysisEngineError::DuplicateEvidence) ); assert_eq!( - MembershipTargetDocument::new("", MembershipTargetKind::Language), + MembershipTargetDocument::new( + "", + MembershipTargetKind::Language, + available("2026-07-01T00:00:00Z"), + ), Err(AnalysisEngineError::InvalidEvidence) ); } @@ -282,7 +300,7 @@ fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { } else { MembershipTargetKind::Language }; - MembershipTargetDocument::new(format!("document-{index}"), kind).expect("document") + document(format!("document-{index}"), kind) }) .collect(); assert_eq!( From f89b5040975b5c24d8652d185001bdf6a6f206b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:57:03 +0900 Subject: [PATCH 54/75] style(analysis): rustfmt cutoff duplicate regression --- .../tests/episode_membership_cutoff_duplicate_contract.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs index 2141bd3f6..0464d83e4 100644 --- a/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs +++ b/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs @@ -54,12 +54,7 @@ fn future_duplicate_identity_cannot_change_historical_cutoff_result() { episode, "2026-07-01T00:00:00Z", ), - assignment( - "escaped-b", - window(9, 15), - episode, - "2026-07-02T00:00:00Z", - ), + assignment("escaped-b", window(9, 15), episode, "2026-07-02T00:00:00Z"), ]; let baseline = execute_episode_membership_run( &request, From cc80a9f18ea86d7b26ae07197d803f2e2bcba782 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:57:14 +0900 Subject: [PATCH 55/75] style(analysis): rustfmt subevent cutoff regression --- .../subevent_containment_cutoff_duplicate_contract.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs index 1f573173b..615ef4f25 100644 --- a/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs +++ b/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs @@ -54,12 +54,7 @@ fn future_duplicate_identity_cannot_change_historical_cutoff_result() { interval(15, 30), "2026-07-01T00:00:00Z", ), - assignment( - "escaped-b", - parent, - interval(0, 20), - "2026-07-02T00:00:00Z", - ), + assignment("escaped-b", parent, interval(0, 20), "2026-07-02T00:00:00Z"), ]; let baseline = execute_subevent_containment_run( &request, From 9357e7110d2e9862edf8b11fb8906efe53ed63b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:57:40 +0900 Subject: [PATCH 56/75] style(analysis): rustfmt membership target fixture --- .../tests/membership_target_execution_contract.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/analysis_engine/tests/membership_target_execution_contract.rs b/crates/analysis_engine/tests/membership_target_execution_contract.rs index 2d86c55b2..1eecf6cb6 100644 --- a/crates/analysis_engine/tests/membership_target_execution_contract.rs +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -21,12 +21,8 @@ fn document( document_id: impl Into, kind: MembershipTargetKind, ) -> MembershipTargetDocument { - MembershipTargetDocument::new( - document_id, - kind, - available("2026-07-01T00:00:00Z"), - ) - .expect("document") + MembershipTargetDocument::new(document_id, kind, available("2026-07-01T00:00:00Z")) + .expect("document") } fn request() -> AnalysisRunRequest { From c89123d7c7165ec3acf2bffcd40676ea81f72c92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:09:53 +0900 Subject: [PATCH 57/75] test(analysis): prove copy identity cutoff replay invariance --- .../tests/copy_identity_execution_contract.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/analysis_engine/tests/copy_identity_execution_contract.rs b/crates/analysis_engine/tests/copy_identity_execution_contract.rs index 4964883cf..693f13138 100644 --- a/crates/analysis_engine/tests/copy_identity_execution_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_execution_contract.rs @@ -153,21 +153,25 @@ fn empty_source_only_copy_only_and_duplicate_identities_fail_closed() { } #[test] -fn future_available_documents_fail_closed() { +fn future_duplicate_identity_cannot_change_historical_cutoff_result() { let request = request(); - let mut documents = mixed_documents(); - documents.push( + let visible = mixed_documents(); + let baseline = execute(&request, &visible).expect("historical baseline"); + + let mut with_future_duplicate = vec![ CopyIdentityDocument::new( - "future-copy", + "copy-b", CopyKind::TemplateCopy, AvailableTime::parse_rfc3339("2026-08-02T00:00:00Z").expect("available"), ) .expect("document"), - ); - assert_eq!( - execute(&request, &documents), - Err(AnalysisEngineError::InvalidEvidence) - ); + ]; + with_future_duplicate.extend(visible); + let replay = execute(&request, &with_future_duplicate) + .expect("future-unavailable duplicate must not enter cutoff admission"); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); } #[test] From 745345544bd424cf410debab6d78a232638db8be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:11:03 +0900 Subject: [PATCH 58/75] fix(analysis): exclude future copy identity evidence before identity admission --- crates/analysis_engine/src/copy_identity_artifact.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index c6b79f414..6e6605100 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -221,7 +221,7 @@ pub fn execute_copy_identity_run( let mut refused_as_transition_count = 0_u64; for document in documents { if !cutoff_eligible(document.available_time(), &knowledge_cutoff) { - return Err(AnalysisEngineError::InvalidEvidence); + continue; } if !seen.insert(document.document_id()) { return Err(AnalysisEngineError::DuplicateEvidence); @@ -252,7 +252,7 @@ pub fn execute_copy_identity_run( } } let document_count = - u64::try_from(documents.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + u64::try_from(seen.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; if document_count < 2 || source_document_count == 0 || template_copy_count == 0 { return Err(AnalysisEngineError::InvalidEvidence); } From 0d0ecf8391d117e7ed9108f489cb22b21418df24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:17:32 +0900 Subject: [PATCH 59/75] docs(adr): align copy identity cutoff admission contract --- docs/adr/0058-copy-identity-analysis-run.md | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/adr/0058-copy-identity-analysis-run.md b/docs/adr/0058-copy-identity-analysis-run.md index 002311430..53ddb6149 100644 --- a/docs/adr/0058-copy-identity-analysis-run.md +++ b/docs/adr/0058-copy-identity-analysis-run.md @@ -1,6 +1,6 @@ # ADR 0058 — Template-copy identity refusals as an analysis-run output profile -**Decision status:** Accepted +**Decision status:** Proposed **Implementation maturity:** active-PR — composed on this branch; not implemented-main **Date:** 2026-08-31 **Supersedes:** None; complements ADR 0003 (copy-versus-source identity) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0057 (simulation method-effect census), ADR 0056 (case-deletion), ADR 0055 (composed fitted-K+lineage), ADR 0054 (export GET), ADR 0053 (Pareto candidate-`K`), or ADR 0052 (joint posterior Laplace draws). @@ -32,8 +32,9 @@ Add the `copy_identity_v1` analysis-run output profile to `CopyKind` values and retained availability times; - requires the request snapshot and knowledge cutoff to match the offered input construction; -- rejects every document whose availability time exceeds the knowledge - cutoff instead of trusting only caller-supplied labels; +- excludes documents whose availability time exceeds the knowledge cutoff + before duplicate-identity or domain admission, so future-unavailable rows + cannot change a historical replay; - invokes `refuse_copy_as_source_identity` and `refuse_copy_as_transition` without reimplementing the copy/source vocabulary; - emits a canonical SHA-256-digested `tepp.copy_identity.v1` artifact with @@ -51,22 +52,25 @@ Add the `copy_identity_v1` analysis-run output profile to because inspect payloads stay metric-free and `tepp.scientific_acceptance.v1` never appears. 3. Bind the existing copy-identity refusals to ADR 0022's analysis-run - profile — accepted. + profile — selected because it preserves the canonical domain vocabulary + while adding the temporal admission boundary. ## Consequences Operators can request cutoff-safe template-copy identity refusals as a digest-bound terminal result. The artifact does not claim MCMC, GPU parity, method-effect estimation, or topic birth/split/merge. -Snapshot/profile/cutoff mismatch, empty or single-kind corpora, and -duplicate document identities fail closed. -Future-available documents also fail closed. +Snapshot/profile/cutoff mismatch, empty or single-kind admitted corpora, +and duplicate document identities visible at the cutoff fail closed. +Future-unavailable documents are excluded from the historical scientific +census before identity admission. ## Verification The PR includes Rust unit and integration tests for mixed source/copy -corpora, empty/source-only/copy-only/duplicate refusal, snapshot / -profile / cutoff mismatch, future availability, and artifact tampering. Run: +corpora, empty/source-only/copy-only/visible-duplicate refusal, snapshot / +profile / cutoff mismatch, historical replay invariance in the presence of +a future-unavailable duplicate identity, and artifact tampering. Run: ```text cargo fmt --all -- --check From 9ee6d60ff4443d7e843e5b20c745a332dca6d097 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:27:00 +0900 Subject: [PATCH 60/75] test(analysis): prove copy artifact wire bound --- .../copy_identity_artifact_bound_contract.rs | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs index 2f334df06..636f845c0 100644 --- a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs @@ -1,9 +1,10 @@ //! Execution and serialization bounds for the copy-identity profile. use analysis_engine::{ - AnalysisEngineError, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, - COPY_IDENTITY_MODEL_CONTRACT_VERSION, COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, - CopyIdentityDocument, MAX_EVIDENCE_UNITS, execute_copy_identity_run, + AnalysisEngineError, COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, + COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, COPY_IDENTITY_MODEL_CONTRACT_VERSION, + COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, CopyIdentityDocument, MAX_EVIDENCE_UNITS, + execute_copy_identity_run, }; use copy_identity::CopyKind; use temporal_core::{AvailableTime, KnowledgeCutoff}; @@ -34,6 +35,26 @@ fn request() -> AnalysisRunRequest { } } +#[test] +fn maximal_valid_copy_artifact_fits_wire_limit() { + let template_copy_count = MAX_EVIDENCE_UNITS as u64 - 1; + let artifact = CopyIdentityArtifact { + schema_version: COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "r".repeat(256), + snapshot_id: "s".repeat(256), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: MAX_EVIDENCE_UNITS as u64, + source_document_count: 1, + template_copy_count, + refused_as_source_count: template_copy_count, + refused_as_transition_count: template_copy_count, + inference_status: "template_copy_is_not_source_identity_not_transition".into(), + }; + + let payload = artifact.to_json().expect("maximal valid artifact"); + assert!(payload.len() < COPY_IDENTITY_ARTIFACT_BYTE_LIMIT); +} + #[test] fn compact_oversized_copy_artifact_fails_closed() { let document_count = MAX_EVIDENCE_UNITS as u64 + 1; From ced4b09dbbc1310c5c2503c787ae69815b4660e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:27:33 +0900 Subject: [PATCH 61/75] fix(analysis): remove unreachable copy artifact egress bound --- crates/analysis_engine/src/copy_identity_artifact.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/analysis_engine/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs index 6e6605100..26468fe61 100644 --- a/crates/analysis_engine/src/copy_identity_artifact.rs +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -22,7 +22,7 @@ pub const COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION: &str = "tepp.copy_identity.v1"; pub const COPY_IDENTITY_MODEL_CONTRACT_VERSION: &str = "copy_identity_v1"; /// Analysis-run output profile required for a copy-identity artifact. pub const COPY_IDENTITY_OUTPUT_PROFILE: &str = "copy_identity_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted copy-identity artifact JSON size. pub const COPY_IDENTITY_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const COPY_IDENTITY_INFERENCE_STATUS: &str = "template_copy_is_not_source_identity_not_transition"; @@ -123,15 +123,10 @@ impl CopyIdentityArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > COPY_IDENTITY_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. From 17b98449b391b178d8a777172536fceca4fc51d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:28:47 +0900 Subject: [PATCH 62/75] test(analysis): prove episode artifact wire bound --- ...sode_membership_artifact_bound_contract.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 crates/analysis_engine/tests/episode_membership_artifact_bound_contract.rs diff --git a/crates/analysis_engine/tests/episode_membership_artifact_bound_contract.rs b/crates/analysis_engine/tests/episode_membership_artifact_bound_contract.rs new file mode 100644 index 000000000..540c17c7c --- /dev/null +++ b/crates/analysis_engine/tests/episode_membership_artifact_bound_contract.rs @@ -0,0 +1,25 @@ +//! Serialization bound contract for the episode-membership profile. + +use analysis_engine::{ + EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, + EpisodeMembershipArtifact, MAX_EVIDENCE_UNITS, +}; + +#[test] +fn maximal_valid_episode_membership_artifact_fits_wire_limit() { + let escaped_count = MAX_EVIDENCE_UNITS as u64 - 1; + let artifact = EpisodeMembershipArtifact { + schema_version: EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "r".repeat(256), + snapshot_id: "s".repeat(256), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count: MAX_EVIDENCE_UNITS as u64, + contained_count: 1, + escaped_count, + refused_as_escape_count: escaped_count, + inference_status: "membership_window_cannot_escape_episode_interval".into(), + }; + + let payload = artifact.to_json().expect("maximal valid artifact"); + assert!(payload.len() < EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT); +} From 21eda89ee8ce68a5ef35621676d0ab7cc72ad0b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:29:16 +0900 Subject: [PATCH 63/75] fix(analysis): remove unreachable episode artifact egress bound --- .../src/episode_membership_artifact.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index 3fa58dd09..bab9133c7 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -19,7 +19,7 @@ pub const EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION: &str = "tepp.episode_membe pub const EPISODE_MEMBERSHIP_MODEL_CONTRACT_VERSION: &str = "episode_membership_v1"; /// Analysis-run output profile required for an episode-membership artifact. pub const EPISODE_MEMBERSHIP_OUTPUT_PROFILE: &str = "episode_membership_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted episode-membership artifact JSON size. pub const EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const EPISODE_MEMBERSHIP_INFERENCE_STATUS: &str = "membership_window_cannot_escape_episode_interval"; @@ -128,15 +128,10 @@ impl EpisodeMembershipArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. From 7dad37591e8f220c95bc08227dfbb7b591b6a4a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:29:45 +0900 Subject: [PATCH 64/75] test(analysis): prove subevent artifact wire bound --- ...ent_containment_artifact_bound_contract.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 crates/analysis_engine/tests/subevent_containment_artifact_bound_contract.rs diff --git a/crates/analysis_engine/tests/subevent_containment_artifact_bound_contract.rs b/crates/analysis_engine/tests/subevent_containment_artifact_bound_contract.rs new file mode 100644 index 000000000..369ade35c --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_artifact_bound_contract.rs @@ -0,0 +1,25 @@ +//! Serialization bound contract for the subevent-containment profile. + +use analysis_engine::{ + MAX_EVIDENCE_UNITS, SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT, + SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, SubeventContainmentArtifact, +}; + +#[test] +fn maximal_valid_subevent_containment_artifact_fits_wire_limit() { + let escaped_count = MAX_EVIDENCE_UNITS as u64 - 1; + let artifact = SubeventContainmentArtifact { + schema_version: SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "r".repeat(256), + snapshot_id: "s".repeat(256), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count: MAX_EVIDENCE_UNITS as u64, + contained_count: 1, + escaped_count, + refused_as_escape_count: escaped_count, + inference_status: "subevent_interval_cannot_escape_parent_interval".into(), + }; + + let payload = artifact.to_json().expect("maximal valid artifact"); + assert!(payload.len() < SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT); +} From b3a3edcc09f6cd25e2067308e83d71f29c6711f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:30:15 +0900 Subject: [PATCH 65/75] fix(analysis): remove unreachable subevent artifact egress bound --- .../src/subevent_containment_artifact.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs index c9c301209..42698edec 100644 --- a/crates/analysis_engine/src/subevent_containment_artifact.rs +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -19,7 +19,7 @@ pub const SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION: &str = "tepp.subevent_co pub const SUBEVENT_CONTAINMENT_MODEL_CONTRACT_VERSION: &str = "subevent_containment_v1"; /// Analysis-run output profile required for a subevent-containment artifact. pub const SUBEVENT_CONTAINMENT_OUTPUT_PROFILE: &str = "subevent_containment_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted subevent-containment artifact JSON size. pub const SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const SUBEVENT_CONTAINMENT_INFERENCE_STATUS: &str = "subevent_interval_cannot_escape_parent_interval"; @@ -128,15 +128,10 @@ impl SubeventContainmentArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. From 8ea980da36e73edde8a0512d8ea6710f17e34861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:32:19 +0900 Subject: [PATCH 66/75] test(analysis): prove membership-target artifact wire bound --- ...mbership_target_artifact_bound_contract.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs diff --git a/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs b/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs new file mode 100644 index 000000000..0b1a69ae5 --- /dev/null +++ b/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs @@ -0,0 +1,31 @@ +//! Serialization bound contract for the membership-target profile. + +use analysis_engine::{ + MAX_EVIDENCE_UNITS, MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, + MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, MembershipTargetArtifact, +}; + +#[test] +fn maximal_valid_membership_target_artifact_fits_wire_limit() { + let language_count = MAX_EVIDENCE_UNITS as u64 - 1; + let artifact = MembershipTargetArtifact { + schema_version: MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "r".repeat(256), + snapshot_id: "s".repeat(256), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: MAX_EVIDENCE_UNITS as u64, + language_count, + episode_count: 0, + template_count: 0, + department_count: 0, + opportunity_pool_count: 0, + entity_count: 1, + project_count: 0, + 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 payload = artifact.to_json().expect("maximal valid artifact"); + assert!(payload.len() < MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT); +} From 441545fa9ea3d994a10f005329734fb249e98f5a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:32:53 +0900 Subject: [PATCH 67/75] fix(analysis): remove unreachable membership-target egress bound --- .../analysis_engine/src/membership_target_artifact.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 52a963e6f..88e5c5d72 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -19,7 +19,7 @@ pub const MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION: &str = "tepp.membership_tar 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. +/// Maximum accepted membership-target 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"; @@ -131,15 +131,10 @@ impl MembershipTargetArtifact { /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization 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) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. From 03f6870c8b5cd201185cd7d53b503e3cea930121 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:35:16 +0900 Subject: [PATCH 68/75] fix(analysis): make episode provider drift guard testable --- .../src/episode_membership_artifact.rs | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index bab9133c7..018aede65 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -270,24 +270,29 @@ fn census_assignments( if !seen.insert(assignment.assignment_id()) { return Err(AnalysisEngineError::DuplicateEvidence); } - match refuse_membership_outside_episode(assignment.membership(), assignment.episode()) { - Ok(()) => { - contained_count = increment(contained_count)?; - } - Err(EpisodeMembershipError::MembershipEscapesEpisode) => { - refused_as_escape_count = increment(refused_as_escape_count)?; - escaped_count = increment(escaped_count)?; - } - Err( - EpisodeMembershipError::InvertedEventWindow - | EpisodeMembershipError::InvalidEpisodePayload - | _, - ) => return Err(AnalysisEngineError::InvalidEvidence), + if classify_membership_result(refuse_membership_outside_episode( + assignment.membership(), + assignment.episode(), + ))? { + contained_count = increment(contained_count)?; + } else { + refused_as_escape_count = increment(refused_as_escape_count)?; + escaped_count = increment(escaped_count)?; } } Ok((contained_count, escaped_count, refused_as_escape_count)) } +fn classify_membership_result( + result: Result<(), EpisodeMembershipError>, +) -> Result { + match result { + Ok(()) => Ok(true), + Err(EpisodeMembershipError::MembershipEscapesEpisode) => Ok(false), + Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + fn increment(count: u64) -> Result { count .checked_add(1) @@ -299,8 +304,10 @@ mod tests { use super::{ EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, EPISODE_MEMBERSHIP_INFERENCE_STATUS, EpisodeMembershipArtifact, + classify_membership_result, }; use crate::AnalysisEngineError; + use episode_membership::EpisodeMembershipError; fn artifact() -> EpisodeMembershipArtifact { EpisodeMembershipArtifact { @@ -401,4 +408,17 @@ mod tests { assert_invalid(&invalid); } } + + #[test] + fn membership_result_classifier_fails_closed_on_provider_drift() { + assert_eq!(classify_membership_result(Ok(())), Ok(true)); + assert_eq!( + classify_membership_result(Err(EpisodeMembershipError::MembershipEscapesEpisode)), + Ok(false) + ); + assert_eq!( + classify_membership_result(Err(EpisodeMembershipError::InvalidEpisodePayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + } } From 722affdd2c02d331bfba3816fbda799059cc1778 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:35:54 +0900 Subject: [PATCH 69/75] fix(analysis): make subevent provider drift guard testable --- .../src/subevent_containment_artifact.rs | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/analysis_engine/src/subevent_containment_artifact.rs b/crates/analysis_engine/src/subevent_containment_artifact.rs index 42698edec..ea95f47b9 100644 --- a/crates/analysis_engine/src/subevent_containment_artifact.rs +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -270,22 +270,29 @@ fn census_assignments( if !seen.insert(assignment.assignment_id()) { return Err(AnalysisEngineError::DuplicateEvidence); } - match refuse_escaped_subevent(assignment.parent(), assignment.child()) { - Ok(()) => { - contained_count = increment(contained_count)?; - } - Err(SubeventContainmentError::SubeventEscapesParent) => { - refused_as_escape_count = increment(refused_as_escape_count)?; - escaped_count = increment(escaped_count)?; - } - Err(SubeventContainmentError::InvalidIntervalPayload | _) => { - return Err(AnalysisEngineError::InvalidEvidence); - } + if classify_subevent_result(refuse_escaped_subevent( + assignment.parent(), + assignment.child(), + ))? { + contained_count = increment(contained_count)?; + } else { + refused_as_escape_count = increment(refused_as_escape_count)?; + escaped_count = increment(escaped_count)?; } } Ok((contained_count, escaped_count, refused_as_escape_count)) } +fn classify_subevent_result( + result: Result<(), SubeventContainmentError>, +) -> Result { + match result { + Ok(()) => Ok(true), + Err(SubeventContainmentError::SubeventEscapesParent) => Ok(false), + Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + fn increment(count: u64) -> Result { count .checked_add(1) @@ -297,8 +304,10 @@ mod tests { use super::{ SUBEVENT_CONTAINMENT_ARTIFACT_BYTE_LIMIT, SUBEVENT_CONTAINMENT_ARTIFACT_SCHEMA_VERSION, SUBEVENT_CONTAINMENT_INFERENCE_STATUS, SubeventContainmentArtifact, + classify_subevent_result, }; use crate::AnalysisEngineError; + use subevent_containment::SubeventContainmentError; fn artifact() -> SubeventContainmentArtifact { SubeventContainmentArtifact { @@ -399,4 +408,17 @@ mod tests { assert_invalid(&invalid); } } + + #[test] + fn subevent_result_classifier_fails_closed_on_provider_drift() { + assert_eq!(classify_subevent_result(Ok(())), Ok(true)); + assert_eq!( + classify_subevent_result(Err(SubeventContainmentError::SubeventEscapesParent)), + Ok(false) + ); + assert_eq!( + classify_subevent_result(Err(SubeventContainmentError::InvalidIntervalPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + } } From badf89e241943a1a0d9ee1405b73937f0c110eec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:02:45 +0900 Subject: [PATCH 70/75] fix(analysis): make membership-target provider guards testable --- .../src/membership_target_artifact.rs | 161 +++++++++++++----- 1 file changed, 123 insertions(+), 38 deletions(-) diff --git a/crates/analysis_engine/src/membership_target_artifact.rs b/crates/analysis_engine/src/membership_target_artifact.rs index 88e5c5d72..2ee5d3dd3 100644 --- a/crates/analysis_engine/src/membership_target_artifact.rs +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -185,6 +185,15 @@ pub struct MembershipTargetExecution { pub terminal_result: AnalysisRunTerminalResult, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TypedMembershipTargetKind { + Language, + Episode, + Template, + Department, + OpportunityPool, +} + /// Execute cutoff-safe membership-target refusals as one analysis-run profile. /// /// The executor invokes [`refuse_collapsed_target`] already on protected main. @@ -250,51 +259,46 @@ pub fn execute_membership_target_run( | 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 => { + require_target_refusal(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Entity, + ))?; + refused_as_entity_count = increment(refused_as_entity_count)?; + require_target_refusal(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Project, + ))?; + refused_as_project_count = increment(refused_as_project_count)?; + match typed_membership_target_kind(document.kind())? { + TypedMembershipTargetKind::Language => { language_count = increment(language_count)?; } - MembershipTargetKind::Episode => { + TypedMembershipTargetKind::Episode => { episode_count = increment(episode_count)?; } - MembershipTargetKind::Template => { + TypedMembershipTargetKind::Template => { template_count = increment(template_count)?; } - MembershipTargetKind::Department => { + TypedMembershipTargetKind::Department => { department_count = increment(department_count)?; } - MembershipTargetKind::OpportunityPool => { + TypedMembershipTargetKind::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)?; + require_target_identity(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Entity, + ))?; entity_count = increment(entity_count)?; } MembershipTargetKind::Project => { - refuse_collapsed_target(document.kind(), MembershipTargetKind::Project) - .map_err(map_membership_target_error)?; + require_target_identity(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Project, + ))?; project_count = increment(project_count)?; } } @@ -347,27 +351,51 @@ pub fn execute_membership_target_run( }) } +fn typed_membership_target_kind( + kind: MembershipTargetKind, +) -> Result { + match kind { + MembershipTargetKind::Language => Ok(TypedMembershipTargetKind::Language), + MembershipTargetKind::Episode => Ok(TypedMembershipTargetKind::Episode), + MembershipTargetKind::Template => Ok(TypedMembershipTargetKind::Template), + MembershipTargetKind::Department => Ok(TypedMembershipTargetKind::Department), + MembershipTargetKind::OpportunityPool => Ok(TypedMembershipTargetKind::OpportunityPool), + MembershipTargetKind::Entity | MembershipTargetKind::Project => { + Err(AnalysisEngineError::InvalidEvidence) + } + } +} + +fn require_target_refusal( + result: Result<(), MembershipTargetError>, +) -> Result<(), AnalysisEngineError> { + match result { + Err(MembershipTargetError::TargetKindCollapsed) => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + +fn require_target_identity( + result: Result<(), MembershipTargetError>, +) -> Result<(), AnalysisEngineError> { + result.map_err(|_| AnalysisEngineError::InvalidEvidence) +} + 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::{ MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, - MEMBERSHIP_TARGET_INFERENCE_STATUS, MembershipTargetArtifact, + MEMBERSHIP_TARGET_INFERENCE_STATUS, MembershipTargetArtifact, TypedMembershipTargetKind, + require_target_identity, require_target_refusal, typed_membership_target_kind, }; use crate::AnalysisEngineError; + use membership_target::{MembershipTargetError, MembershipTargetKind}; fn artifact() -> MembershipTargetArtifact { MembershipTargetArtifact { @@ -480,4 +508,61 @@ mod tests { assert_invalid(&invalid); } } + + #[test] + fn membership_target_provider_contract_guards_fail_closed() { + assert_eq!( + require_target_refusal(Err(MembershipTargetError::TargetKindCollapsed)), + Ok(()) + ); + assert_eq!( + require_target_refusal(Ok(())), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_target_refusal(Err(MembershipTargetError::InvalidTargetPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!(require_target_identity(Ok(())), Ok(())); + assert_eq!( + require_target_identity(Err(MembershipTargetError::TargetKindCollapsed)), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_target_identity(Err(MembershipTargetError::InvalidTargetPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + + #[test] + fn typed_membership_target_classifier_refuses_persistence_kinds() { + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Language), + Ok(TypedMembershipTargetKind::Language) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Episode), + Ok(TypedMembershipTargetKind::Episode) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Template), + Ok(TypedMembershipTargetKind::Template) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Department), + Ok(TypedMembershipTargetKind::Department) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::OpportunityPool), + Ok(TypedMembershipTargetKind::OpportunityPool) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Entity), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + typed_membership_target_kind(MembershipTargetKind::Project), + Err(AnalysisEngineError::InvalidEvidence) + ); + } } From 053706f7da241fdda7529a43d5995c959aac70c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:06:13 +0900 Subject: [PATCH 71/75] style(analysis): apply exact rustfmt diagnostics --- crates/analysis_engine/src/episode_membership_artifact.rs | 3 +-- .../tests/copy_identity_artifact_bound_contract.rs | 7 +++---- .../tests/membership_target_artifact_bound_contract.rs | 3 ++- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/analysis_engine/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs index 018aede65..2c137d5f9 100644 --- a/crates/analysis_engine/src/episode_membership_artifact.rs +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -303,8 +303,7 @@ fn increment(count: u64) -> Result { mod tests { use super::{ EPISODE_MEMBERSHIP_ARTIFACT_BYTE_LIMIT, EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, - EPISODE_MEMBERSHIP_INFERENCE_STATUS, EpisodeMembershipArtifact, - classify_membership_result, + EPISODE_MEMBERSHIP_INFERENCE_STATUS, EpisodeMembershipArtifact, classify_membership_result, }; use crate::AnalysisEngineError; use episode_membership::EpisodeMembershipError; diff --git a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs index 636f845c0..0065b5950 100644 --- a/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs +++ b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs @@ -1,10 +1,9 @@ //! Execution and serialization bounds for the copy-identity profile. use analysis_engine::{ - AnalysisEngineError, COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, - COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, COPY_IDENTITY_MODEL_CONTRACT_VERSION, - COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, CopyIdentityDocument, MAX_EVIDENCE_UNITS, - execute_copy_identity_run, + AnalysisEngineError, COPY_IDENTITY_ARTIFACT_BYTE_LIMIT, COPY_IDENTITY_ARTIFACT_SCHEMA_VERSION, + COPY_IDENTITY_MODEL_CONTRACT_VERSION, COPY_IDENTITY_OUTPUT_PROFILE, CopyIdentityArtifact, + CopyIdentityDocument, MAX_EVIDENCE_UNITS, execute_copy_identity_run, }; use copy_identity::CopyKind; use temporal_core::{AvailableTime, KnowledgeCutoff}; diff --git a/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs b/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs index 0b1a69ae5..882aff921 100644 --- a/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs +++ b/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs @@ -23,7 +23,8 @@ fn maximal_valid_membership_target_artifact_fits_wire_limit() { project_count: 0, refused_as_entity_count: language_count, refused_as_project_count: language_count, - inference_status: "language_episode_template_department_opportunity_pool_are_not_entities".into(), + inference_status: "language_episode_template_department_opportunity_pool_are_not_entities" + .into(), }; let payload = artifact.to_json().expect("maximal valid artifact"); From d669d6f4367f9dfb88289920e22698d6963fe7cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:35:43 +0900 Subject: [PATCH 72/75] test(analysis): pin inferred-status historical replay --- ...ferred_status_cutoff_duplicate_contract.rs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 crates/analysis_engine/tests/inferred_status_cutoff_duplicate_contract.rs diff --git a/crates/analysis_engine/tests/inferred_status_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/inferred_status_cutoff_duplicate_contract.rs new file mode 100644 index 000000000..216526f88 --- /dev/null +++ b/crates/analysis_engine/tests/inferred_status_cutoff_duplicate_contract.rs @@ -0,0 +1,84 @@ +//! Historical replay contract for inferred-status evidence admission. + +use analysis_engine::{ + INFERRED_STATUS_MODEL_CONTRACT_VERSION, INFERRED_STATUS_OUTPUT_PROFILE, InferredStatusEvidence, + execute_inferred_status_run, +}; +use inferred_status::EvidenceStatus; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("valid availability") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("valid cutoff") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "inferred-status-cutoff-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-inferred-status-cutoff".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: INFERRED_STATUS_MODEL_CONTRACT_VERSION.into(), + output_profile: INFERRED_STATUS_OUTPUT_PROFILE.into(), + } +} + +fn evidence( + evidence_id: &str, + status: EvidenceStatus, + available_at: &str, +) -> InferredStatusEvidence { + InferredStatusEvidence::new(evidence_id, status, available(available_at)).expect("valid evidence") +} + +fn execute(evidence: &[InferredStatusEvidence]) -> analysis_engine::InferredStatusExecution { + let request = request(); + let accepted = AnalysisRunAccepted::new( + "run-inferred-status-cutoff", + "accepted", + &request.idempotency_key, + ) + .expect("accepted run"); + execute_inferred_status_run( + &request, + &accepted, + "snapshot-inferred-status-cutoff", + cutoff(), + evidence, + "2026-08-02T00:00:00Z", + ) + .expect("historical execution") +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_result() { + let visible = vec![ + evidence( + "observed-a", + EvidenceStatus::Observed, + "2026-07-01T00:00:00Z", + ), + evidence( + "inferred-b", + EvidenceStatus::Inferred, + "2026-07-02T00:00:00Z", + ), + ]; + let baseline = execute(&visible); + + let mut with_future_duplicate = vec![evidence( + "inferred-b", + EvidenceStatus::Inferred, + "2026-08-02T00:00:00Z", + )]; + with_future_duplicate.extend(visible); + let replay = execute(&with_future_duplicate); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} From 4591837f4d11617232779e0447a56751a0b5cce0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:38:36 +0900 Subject: [PATCH 73/75] fix(analysis): admit inferred evidence before identity checks --- crates/analysis_engine/src/inferred_status_artifact.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/src/inferred_status_artifact.rs b/crates/analysis_engine/src/inferred_status_artifact.rs index 89d8647f2..926c9e5d2 100644 --- a/crates/analysis_engine/src/inferred_status_artifact.rs +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -262,12 +262,12 @@ fn census_evidence( let mut refused_as_observed_count = 0_u64; let mut refused_as_transition_count = 0_u64; for row in evidence { - if !seen.insert(row.evidence_id()) { - return Err(AnalysisEngineError::DuplicateEvidence); - } if row.available_time().instant() > knowledge_cutoff.instant() { continue; } + if !seen.insert(row.evidence_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } match row.status() { EvidenceStatus::Observed => { refuse_inferred_as_observed(row.status()).map_err(map_inferred_status_error)?; From f0e9f425843d1ff0628e18f4b963a217aef3ed0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 04:59:19 +0900 Subject: [PATCH 74/75] test(analysis): pin location-membership historical replay --- ...on_membership_cutoff_duplicate_contract.rs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 crates/analysis_engine/tests/location_membership_cutoff_duplicate_contract.rs diff --git a/crates/analysis_engine/tests/location_membership_cutoff_duplicate_contract.rs b/crates/analysis_engine/tests/location_membership_cutoff_duplicate_contract.rs new file mode 100644 index 000000000..47311a30a --- /dev/null +++ b/crates/analysis_engine/tests/location_membership_cutoff_duplicate_contract.rs @@ -0,0 +1,92 @@ +//! Historical replay contract for location-membership evidence admission. + +use analysis_engine::{ + LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION, LOCATION_MEMBERSHIP_OUTPUT_PROFILE, + LocationMembershipDocument, execute_location_membership_run, +}; +use location_membership::LocationKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("valid availability") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("valid cutoff") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "location-membership-cutoff-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-location-membership-cutoff".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LOCATION_MEMBERSHIP_MODEL_CONTRACT_VERSION.into(), + output_profile: LOCATION_MEMBERSHIP_OUTPUT_PROFILE.into(), + } +} + +fn document( + document_id: &str, + kind: LocationKind, + available_at: &str, +) -> LocationMembershipDocument { + LocationMembershipDocument::new(document_id, kind, available(available_at)) + .expect("valid document") +} + +fn execute( + documents: &[LocationMembershipDocument], +) -> analysis_engine::LocationMembershipExecution { + let request = request(); + let accepted = AnalysisRunAccepted::new( + "run-location-membership-cutoff", + "accepted", + &request.idempotency_key, + ) + .expect("accepted run"); + execute_location_membership_run( + &request, + &accepted, + "snapshot-location-membership-cutoff", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) + .expect("historical execution") +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_result() { + let visible = vec![ + document( + "loc-a", + LocationKind::Location, + "2026-07-01T00:00:00Z", + ), + document( + "ent-b", + LocationKind::EntityIdentity, + "2026-07-02T00:00:00Z", + ), + document( + "lang-c", + LocationKind::LanguageChannel, + "2026-07-03T00:00:00Z", + ), + ]; + let baseline = execute(&visible); + + let mut with_future_duplicate = vec![document( + "loc-a", + LocationKind::Location, + "2026-08-02T00:00:00Z", + )]; + with_future_duplicate.extend(visible); + let replay = execute(&with_future_duplicate); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} From 03f8de2ed0a0fb842d2022d411814e440df7cfb4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:01:11 +0900 Subject: [PATCH 75/75] fix(analysis): exclude future location evidence from historical census --- crates/analysis_engine/src/location_membership_artifact.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/analysis_engine/src/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs index 61164e171..40b584140 100644 --- a/crates/analysis_engine/src/location_membership_artifact.rs +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -224,7 +224,7 @@ pub fn execute_location_membership_run( let mut refused_as_language_channel_count = 0_u64; for document in documents { if document.available_time().instant() > knowledge_cutoff.instant() { - return Err(AnalysisEngineError::InvalidEvidence); + continue; } if !seen.insert(document.document_id()) { return Err(AnalysisEngineError::DuplicateEvidence); @@ -251,7 +251,8 @@ pub fn execute_location_membership_run( } } } - let document_count = documents.len() as u64; + let document_count = + u64::try_from(seen.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; if document_count < 2 || location_count == 0 || entity_identity_count