From cc8ed995ca20d4f6a8b6c597d3bd4e6944ae0dba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 10:44:40 +0000 Subject: [PATCH 01/47] feat(analysis): bind CWC within/between slopes to an analysis-run profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators can request the existing psychometric_core Enders–Tofighi CWC composition as longitudinal_cwc_v1. Rows unavailable at the request cutoff are excluded; the digest-bound tepp.longitudinal_cwc.v1 artifact records within, between, and contextual slopes and refuses causal promotion. Not a new ESEM/DSEM estimator, not a Driver p.16 std restore, and not persistence. --- CHANGELOG.md | 2 + Cargo.lock | 1 + crates/analysis_engine/Cargo.toml | 2 + crates/analysis_engine/src/lib.rs | 40 +- .../src/longitudinal_cwc_artifact.rs | 415 ++++++++++++++++++ .../longitudinal_cwc_execution_contract.rs | 243 ++++++++++ docs/TRACEABILITY.md | 1 + .../adr/0033-longitudinal-cwc-analysis-run.md | 73 +++ docs/adr/README.md | 2 + .../longitudinal-cwc-analysis-run.md | 18 + 10 files changed, 795 insertions(+), 2 deletions(-) create mode 100644 crates/analysis_engine/src/longitudinal_cwc_artifact.rs create mode 100644 crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs create mode 100644 docs/adr/0033-longitudinal-cwc-analysis-run.md create mode 100644 docs/doctoring/longitudinal-cwc-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..e9915aac3 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] +- `analysis_engine` binds Enders and Tofighi (2007) CWC within/between/contextual OLS (`recover_cluster_mean_within_between_slopes`) to the `longitudinal_cwc_v1` analysis-run output profile. Rows unavailable at the request cutoff are excluded; the digest-bound `tepp.longitudinal_cwc.v1` artifact records the three slopes and refuses causal promotion. This is not a new ESEM/DSEM estimator, not a Driver p.16 `std` restore, and not persistence. + - `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..28a0f0cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "psychometric_core", "relation_graph", "serde", "serde_json", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..d1f55851c 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" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -26,6 +27,7 @@ uuid.workspace = true [dev-dependencies] corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } [lints] diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..a0930f704 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,13 +8,16 @@ //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic //! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Longitudinal CWC composition +//! is invoked through [`psychometric_core`] and is not a causal estimand. mod case_deletion_refit; mod lineage_criterion; +mod longitudinal_cwc_artifact; mod topic_context_posterior; mod topic_lineage_artifact; +use psychometric_core::PsychometricError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -46,6 +49,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Longitudinal CWC composition artifact and execution contracts. +pub use longitudinal_cwc_artifact::{ + LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION, LONGITUDINAL_CWC_OUTPUT_PROFILE, + LongitudinalClusterScore, LongitudinalCwcArtifact, LongitudinalCwcExecution, + execute_longitudinal_cwc_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 +258,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A psychometric recovery rejected the offered coordinates. + Psychometric(PsychometricError), + /// A longitudinal CWC artifact violated its bounded schema or count invariants. + InvalidLongitudinalCwcArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +276,8 @@ 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::Psychometric(error) => return error.fmt(formatter), + Self::InvalidLongitudinalCwcArtifact => "invalid longitudinal CWC artifact", }; formatter.write_str(message) } @@ -281,6 +297,12 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: PsychometricError) -> Self { + Self::Psychometric(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -413,7 +435,8 @@ mod tests { use super::{ ANALYSIS_ARTIFACT_SCHEMA_VERSION, ANALYSIS_STATISTIC_COUNT, AnalysisCorpus, AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, - MAX_EVIDENCE_UNITS, TopicMeasurementError, add_membership_count, execute_analysis_run, + MAX_EVIDENCE_UNITS, PsychometricError, TopicMeasurementError, add_membership_count, + execute_analysis_run, }; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -681,6 +704,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::Psychometric(PsychometricError::CausalUnderidentified), + "temporal precedence is not causal identification", + ), + ( + AnalysisEngineError::InvalidLongitudinalCwcArtifact, + "invalid longitudinal CWC artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,6 +720,11 @@ mod tests { assert_eq!(converted.to_string(), "invalid API wire payload"); let from_topic: AnalysisEngineError = TopicMeasurementError::DidNotConverge.into(); assert_eq!(from_topic.to_string(), "topic estimator did not converge"); + let from_psych: AnalysisEngineError = PsychometricError::CausalUnderidentified.into(); + assert_eq!( + from_psych.to_string(), + "temporal precedence is not causal identification" + ); assert_eq!( add_membership_count(u64::MAX, 1), Err(AnalysisEngineError::ArithmeticOverflow) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs new file mode 100644 index 000000000..0e0e79f98 --- /dev/null +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -0,0 +1,415 @@ +//! Digest-bound CWC within/between composition as an analysis-run profile. + +use psychometric_core::{ + CausalHeuristic, ClusteredScore, PsychometricError, claim_causal_effect, + recover_cluster_mean_within_between_slopes, +}; +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 longitudinal CWC artifact. +pub const LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION: &str = "tepp.longitudinal_cwc.v1"; +/// Model contract required by the CWC composition execution path. +pub const LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION: &str = "longitudinal_cwc_v1"; +/// Analysis-run output profile required for a longitudinal CWC artifact. +pub const LONGITUDINAL_CWC_OUTPUT_PROFILE: &str = "longitudinal_cwc_v1"; +/// Maximum canonical artifact JSON size. +pub const LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const LONGITUDINAL_CWC_INFERENCE_STATUS: &str = "composed_cwc_slopes_not_causal"; + +/// One already-mapped clustered score offered to a cutoff-safe CWC run. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LongitudinalClusterScore { + cluster_key: u64, + predictor: f64, + outcome: f64, + available_time: AvailableTime, +} + +impl LongitudinalClusterScore { + /// Bind one clustered predictor–outcome pair to an availability clock. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when either coordinate + /// is non-finite. + pub fn new( + cluster_key: u64, + predictor: f64, + outcome: f64, + available_time: AvailableTime, + ) -> Result { + if !predictor.is_finite() || !outcome.is_finite() { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + cluster_key, + predictor, + outcome, + available_time, + }) + } + + /// Return the cluster identity. + #[must_use] + pub const fn cluster_key(self) -> u64 { + self.cluster_key + } + + /// Return the already-mapped predictor. + #[must_use] + pub const fn predictor(self) -> f64 { + self.predictor + } + + /// Return the already-mapped outcome. + #[must_use] + pub const fn outcome(self) -> f64 { + self.outcome + } + + /// Return the availability clock used for cutoff eligibility. + #[must_use] + pub const fn available_time(self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded CWC composition consumed by analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LongitudinalCwcArtifact { + /// 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 by the composition. + pub knowledge_cutoff: String, + /// Eligible clustered rows after cutoff. + pub row_count: u64, + /// Distinct clusters among eligible rows. + pub cluster_count: u64, + /// Rows excluded because availability was after the cutoff. + pub excluded_after_cutoff_count: u64, + /// Within-cluster OLS slope after CWC. + pub within_slope: f64, + /// Between-cluster OLS slope of cluster means. + pub between_slope: f64, + /// CWC contextual effect `between − within`. + pub contextual_effect: f64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl LongitudinalCwcArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidLongitudinalCwcArtifact`] when the + /// schema, identifiers, counts, slopes, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; + 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() > LONGITUDINAL_CWC_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> { + if self.schema_version != LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.row_count < 2 + || self.cluster_count < 2 + || self.cluster_count > self.row_count + || !self.within_slope.is_finite() + || !self.between_slope.is_finite() + || !self.contextual_effect.is_finite() + || self.inference_status != LONGITUDINAL_CWC_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact); + } + Ok(()) + } +} + +/// One completed CWC artifact and its request-bound terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct LongitudinalCwcExecution { + /// Digest-bound completed composition artifact. + pub artifact: LongitudinalCwcArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +struct EligibleCwcRows { + scores: Vec, + excluded_after_cutoff_count: u64, +} + +fn admit_scores_at_cutoff( + scores: &[LongitudinalClusterScore], + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + if scores.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + let mut eligible = Vec::new(); + let mut excluded_after_cutoff_count = 0_u64; + for score in scores { + if score.available_time.instant() <= knowledge_cutoff.instant() { + eligible.push(ClusteredScore { + cluster_key: score.cluster_key, + predictor: score.predictor, + outcome: score.outcome, + }); + } else { + excluded_after_cutoff_count += 1; + } + } + if eligible.is_empty() { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput, + )); + } + Ok(EligibleCwcRows { + scores: eligible, + excluded_after_cutoff_count, + }) +} + +/// Execute cutoff-safe CWC within/between composition as one analysis-run profile. +/// +/// The caller supplies already-mapped clustered coordinates. This executor does +/// not invent an ESEM/DSEM estimator, persist rows, or treat the recovered +/// slopes as a causal effect. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, psychometric +/// recovery failure, or invalid artifact error. +pub fn execute_longitudinal_cwc_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + scores: &[LongitudinalClusterScore], + 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 != LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION + || request.output_profile != LONGITUDINAL_CWC_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let eligible = admit_scores_at_cutoff(scores, knowledge_cutoff)?; + let slopes = recover_cluster_mean_within_between_slopes(&eligible.scores)?; + let _ = claim_causal_effect(CausalHeuristic::TemporalPrecedence); + + let mut clusters = std::collections::BTreeSet::new(); + for score in &eligible.scores { + clusters.insert(score.cluster_key); + } + let row_count = u64::try_from(eligible.scores.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let cluster_count = + u64::try_from(clusters.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let artifact = LongitudinalCwcArtifact { + schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + row_count, + cluster_count, + excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, + within_slope: slopes.within_slope, + between_slope: slopes.between_slope, + contextual_effect: slopes.contextual_effect, + inference_status: LONGITUDINAL_CWC_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "longitudinal_cwc", + row_count, + 3, + LONGITUDINAL_CWC_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("longitudinal_cwc_artifact_{}", &digest[..16]), + digest, + LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(LongitudinalCwcExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_CWC_INFERENCE_STATUS, LongitudinalCwcArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> LongitudinalCwcArtifact { + LongitudinalCwcArtifact { + schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + row_count: 4, + cluster_count: 2, + excluded_after_cutoff_count: 0, + within_slope: 0.5, + between_slope: 2.0, + contextual_effect: 1.5, + inference_status: LONGITUDINAL_CWC_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &LongitudinalCwcArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + LongitudinalCwcArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + LongitudinalCwcArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); + assert_eq!( + LongitudinalCwcArtifact::from_json( + &"x".repeat(LONGITUDINAL_CWC_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.row_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.cluster_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.cluster_count = 5; + value + }, + { + let mut value = artifact.clone(); + value.within_slope = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.between_slope = f64::INFINITY; + value + }, + { + let mut value = artifact.clone(); + value.contextual_effect = f64::NEG_INFINITY; + 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/longitudinal_cwc_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs new file mode 100644 index 000000000..b53f18b62 --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs @@ -0,0 +1,243 @@ +//! End-to-end contract for cutoff-safe longitudinal CWC composition. + +use analysis_engine::{ + AnalysisEngineError, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION, LONGITUDINAL_CWC_OUTPUT_PROFILE, + LongitudinalClusterScore, MAX_EVIDENCE_UNITS, execute_longitudinal_cwc_run, +}; +use psychometric_core::PsychometricError; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn noiseless_rows() -> Vec { + vec![ + LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), + LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), + LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-07-01T00:00:00Z")).expect("r3"), + LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-07-01T00:00:00Z")).expect("r4"), + ] +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "longitudinal-cwc-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-longitudinal-cwc".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), + output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-longitudinal-cwc", "accepted", &request.idempotency_key) + .expect("accepted") +} + +#[test] +fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { + let request = request(); + let accepted = accepted(&request); + let rows = noiseless_rows(); + let execution = execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ) + .expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.row_count, 4); + assert_eq!(execution.artifact.cluster_count, 2); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); + assert!((execution.artifact.within_slope - 0.5).abs() < 1e-12); + assert!((execution.artifact.between_slope - 2.0).abs() < 1e-12); + assert!((execution.artifact.contextual_effect - 1.5).abs() < 1e-12); + assert_eq!( + execution.artifact.inference_status, + "composed_cwc_slopes_not_causal" + ); + 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(LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION) + ); + assert_eq!(rows[0].cluster_key(), 1); + assert!((rows[0].predictor() - 0.0).abs() < f64::EPSILON); + assert!((rows[0].outcome() - 2.0).abs() < f64::EPSILON); + assert_eq!(rows[0].available_time(), available("2026-07-01T00:00:00Z")); +} + +#[test] +fn execution_excludes_rows_unavailable_at_the_request_cutoff() { + let request = request(); + let accepted = accepted(&request); + let mut rows = noiseless_rows(); + rows.push( + LongitudinalClusterScore::new(3, 8.0, 20.0, available("2026-08-15T00:00:00Z")) + .expect("late"), + ); + let execution = execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ) + .expect("execution"); + assert_eq!(execution.artifact.row_count, 4); + assert_eq!(execution.artifact.cluster_count, 2); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); + assert!((execution.artifact.within_slope - 0.5).abs() < 1e-12); + assert!((execution.artifact.between_slope - 2.0).abs() < 1e-12); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let accepted = accepted(&request); + let rows = noiseless_rows(); + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + "other-snapshot", + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "other-profile".into(); + value + }, + ] { + assert_eq!( + execute_longitudinal_cwc_run( + &invalid_request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { + let request = request(); + let accepted = accepted(&request); + assert_eq!( + LongitudinalClusterScore::new(1, f64::NAN, 1.0, available("2026-07-01T00:00:00Z")), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut early_request = request.clone(); + early_request.knowledge_cutoff = "2026-06-01T00:00:00Z".into(); + let too_early = KnowledgeCutoff::parse_rfc3339("2026-06-01T00:00:00Z").expect("cutoff"); + assert_eq!( + execute_longitudinal_cwc_run( + &early_request, + &accepted, + "snapshot-longitudinal-cwc", + too_early, + &noiseless_rows(), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput + )) + ); + + let late_cluster_two = vec![ + LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), + LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), + LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-08-15T00:00:00Z")).expect("r3"), + LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-08-15T00:00:00Z")).expect("r4"), + ]; + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &late_cluster_two, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InsufficientClusters + )) + ); + + let wrong_receipt = AnalysisRunAccepted::new("run-longitudinal-cwc", "accepted", "other-key") + .expect("accepted"); + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &wrong_receipt, + "snapshot-longitudinal-cwc", + cutoff(), + &noiseless_rows(), + "2026-08-02T00:00:00Z", + ) + .expect_err("receipt"), + AnalysisEngineError::Api(tepp_api::ApiError::InvalidWirePayload) + ); + + let oversized = + vec![ + LongitudinalClusterScore::new(1, 0.0, 1.0, available("2026-07-01T00:00:00Z")) + .expect("row"); + MAX_EVIDENCE_UNITS + 1 + ]; + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &oversized, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..249dad53b 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 | +| cutoff-safe longitudinal CWC composition | ADR 0005/0033; Enders & Tofighi (2007) | `analysis_engine` `longitudinal_cwc_v1` binds `psychometric_core` CWC within/between/contextual slopes to a digest-bound `tepp.longitudinal_cwc.v1` artifact; causal promotion refused; not ESEM/DSEM estimation | 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/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md new file mode 100644 index 000000000..f515e58aa --- /dev/null +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -0,0 +1,73 @@ +# ADR 0033 — Longitudinal CWC composition 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 0005 (ESEM/DSEM interpretation) and ADR 0022 (cutoff-safe analysis-run execution). +**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 recovers Enders and Tofighi (2007) cluster-mean-centered +within/between OLS and the CWC contextual effect inside `psychometric_core`. +Operators still cannot request that composition as a digest-bound analysis-run +output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169). +A second Driver p.16 `std`-family restore would not close this operator-visible +gap. + +## Decision + +Add the `longitudinal_cwc_v1` analysis-run output profile to `analysis_engine`. +The executor: + +- consumes already-mapped clustered predictor/outcome coordinates plus + `available_time`; +- excludes rows whose availability is later than the request `knowledge_cutoff`; +- invokes `recover_cluster_mean_within_between_slopes` without reimplementing + CWC; +- invokes `claim_causal_effect` so temporal precedence cannot promote the + slopes to a causal estimand; +- emits a canonical SHA-256-digested `tepp.longitudinal_cwc.v1` artifact with + row/cluster counts, excluded-after-cutoff count, within/between/contextual + slopes, and inference status `composed_cwc_slopes_not_causal`; +- does not invent an ESEM/DSEM sampler, persist rows, or claim strong + invariance or Rubin pooling. + +This is two-level OLS composition, not DSEM, not RI-CLPM, and not a +random-effects sampler. + +## Alternatives considered + +1. Restore another Driver p.16 standardised matrix — rejected because those + recoveries are already a live micro-PR family and do not bind composition to + an analysis run. +2. Put CWC execution into `tepp_api` — rejected because transport contracts and + scientific composition would become one service boundary. +3. Bind the existing `psychometric_core` CWC recovery to ADR 0022's analysis-run + profile — accepted. + +## Consequences + +Operators can request cutoff-safe within/between/contextual slopes as a +digest-bound terminal result. The artifact is not a causal effect, not an ESEM +fit, and not implemented-main until exact-head Checks and two independent +approvals land. + +## Verification + +```text +cargo fmt -p analysis_engine -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +Known-truth noiseless CWC recovers within `0.5`, between `2.0`, contextual +`1.5`. Cutoff exclusion, snapshot/profile mismatch, empty eligibility, and +single-cluster remainder fail closed. + +## Rollback and supersession + +Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps CWC distinct +from between-cluster effects and from causal identification. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..569c9bce6 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. | +| [0033](0033-longitudinal-cwc-analysis-run.md) | Longitudinal CWC composition as an analysis-run output profile | Accepted | active-PR | Binds Enders–Tofighi CWC within/between/contextual slopes to `longitudinal_cwc_v1`; cutoff-filters rows; refuses causal promotion. | | [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. | @@ -140,6 +141,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **longitudinal CWC analysis-run output profile:** ADR 0033. ## Change and supersession rule diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md new file mode 100644 index 000000000..933fc84bb --- /dev/null +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -0,0 +1,18 @@ +# Longitudinal CWC analysis-run bind + +**Review date:** 2026-08-31 +**Active slice:** GAP-006 / issue #169 remaining operator-visible composition + +Protected main already recovers Enders and Tofighi (2007) CWC within/between +OLS in `psychometric_core`. This slice binds that recovery to +`analysis_engine` as a cutoff-safe analysis-run output: eligibility against +the request knowledge cutoff, digest-bound `tepp.longitudinal_cwc.v1`, and an +explicit refusal to treat the slopes as a causal effect. + +This is not a new estimator, not a Driver p.16 `std` restore, not persistence, +and not implemented-main. + +## Evidence boundary + +Exact-head checks, independent review, and protected merge are required before +the profile can be promoted. From caa8f424699c721fab9361309b1c08c39f0c1b74 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 02:45:00 +0900 Subject: [PATCH 02/47] test(analysis): close longitudinal CWC coverage gaps --- .../src/longitudinal_cwc_artifact.rs | 12 ++++++---- .../longitudinal_cwc_execution_contract.rs | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 0e0e79f98..97927ac32 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -133,14 +133,11 @@ impl LongitudinalCwcArtifact { /// /// # 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() > LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } Ok(payload) } @@ -228,6 +225,10 @@ fn admit_scores_at_cutoff( /// /// Returns a request/receipt/snapshot/cutoff/profile error, psychometric /// recovery failure, or invalid artifact error. +#[expect( + clippy::missing_panics_doc, + reason = "bounded summary constants cannot fail" +)] pub fn execute_longitudinal_cwc_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -280,7 +281,8 @@ pub fn execute_longitudinal_cwc_run( row_count, 3, LONGITUDINAL_CWC_INFERENCE_STATUS, - )?; + ) + .expect("bounded longitudinal CWC summary constants are valid"); let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, diff --git a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs index b53f18b62..96f7eb75d 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs @@ -170,6 +170,10 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { LongitudinalClusterScore::new(1, f64::NAN, 1.0, available("2026-07-01T00:00:00Z")), Err(AnalysisEngineError::InvalidEvidence) ); + assert_eq!( + LongitudinalClusterScore::new(1, 1.0, f64::NAN, available("2026-07-01T00:00:00Z")), + Err(AnalysisEngineError::InvalidEvidence) + ); let mut early_request = request.clone(); early_request.knowledge_cutoff = "2026-06-01T00:00:00Z".into(); @@ -241,3 +245,22 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn execution_refuses_invalid_completion_time() { + let request = request(); + let accepted = accepted(&request); + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &noiseless_rows(), + "invalid", + ), + Err(AnalysisEngineError::Api( + tepp_api::ApiError::InvalidWirePayload + )) + ); +} From 8a5ae8592e795ca1a05052780180b1e1e85c6caf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:51:05 +0900 Subject: [PATCH 03/47] docs(analysis_engine): keep the rustdoc adjacent to execute_longitudinal_cwc_run The repository docstring contract scans attribute lines only when they start with '#[', so a multi-line #[expect(...)] between the '///' block and 'pub fn' hid the documentation. Move the attribute above the doc comment; attribute order has no semantic effect. Co-Authored-By: Claude Fable 5.1 --- crates/analysis_engine/src/longitudinal_cwc_artifact.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 97927ac32..643d0ecd2 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -215,6 +215,10 @@ fn admit_scores_at_cutoff( }) } +#[expect( + clippy::missing_panics_doc, + reason = "bounded summary constants cannot fail" +)] /// Execute cutoff-safe CWC within/between composition as one analysis-run profile. /// /// The caller supplies already-mapped clustered coordinates. This executor does @@ -225,10 +229,6 @@ fn admit_scores_at_cutoff( /// /// Returns a request/receipt/snapshot/cutoff/profile error, psychometric /// recovery failure, or invalid artifact error. -#[expect( - clippy::missing_panics_doc, - reason = "bounded summary constants cannot fail" -)] pub fn execute_longitudinal_cwc_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, From 6fd006e6d582c0a79cca7c007f7db4e8540409d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:00:18 +0900 Subject: [PATCH 04/47] test(analysis): expose longitudinal CWC contract gaps --- .../longitudinal_cwc_review_regressions.rs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs new file mode 100644 index 000000000..11e9628db --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -0,0 +1,120 @@ +//! Regression contracts for longitudinal CWC analysis-run integrity. + +use analysis_engine::{ + AnalysisEngineError, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, + LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION, LONGITUDINAL_CWC_OUTPUT_PROFILE, + LongitudinalClusterScore, LongitudinalCwcArtifact, MAX_EVIDENCE_UNITS, + execute_longitudinal_cwc_run, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn request(cutoff: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "longitudinal-cwc-review-regression".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-longitudinal-cwc".into(), + knowledge_cutoff: cutoff.into(), + model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), + output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), + } +} + +fn rows() -> Vec { + vec![ + LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), + LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), + LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-07-01T00:00:00Z")).expect("r3"), + LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-07-01T00:00:00Z")).expect("r4"), + ] +} + +fn artifact() -> LongitudinalCwcArtifact { + LongitudinalCwcArtifact { + schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-longitudinal-cwc".into(), + snapshot_id: "snapshot-longitudinal-cwc".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + row_count: 4, + cluster_count: 2, + excluded_after_cutoff_count: 0, + within_slope: 0.5, + between_slope: 2.0, + contextual_effect: 1.5, + inference_status: "composed_cwc_slopes_not_causal".into(), + } +} + +#[test] +fn equivalent_cutoff_instants_bind_and_provider_status_stays_separate() { + let request = request("2026-08-01T01:00:00+01:00"); + let accepted = AnalysisRunAccepted::new( + "run-longitudinal-cwc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + + let execution = execute_longitudinal_cwc_run( + &request, + &accepted, + "snapshot-longitudinal-cwc", + cutoff(), + &rows(), + "2026-08-02T00:00:00Z", + ) + .expect("equivalent cutoff instant must be accepted"); + + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); + assert_eq!( + execution.artifact.inference_status, + "composed_cwc_slopes_not_causal" + ); +} + +#[test] +fn artifact_refuses_inconsistent_contextual_effect() { + let mut tampered = artifact(); + tampered.contextual_effect = 0.0; + assert_eq!( + tampered.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); +} + +#[test] +fn artifact_refuses_counts_impossible_for_the_executor() { + let mut oversized = artifact(); + oversized.row_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("limit") + 1; + oversized.cluster_count = 2; + assert_eq!( + oversized.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); + + let mut impossible_total = artifact(); + impossible_total.row_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("limit"); + impossible_total.cluster_count = 2; + impossible_total.excluded_after_cutoff_count = 1; + assert_eq!( + impossible_total.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); +} From ec2bc21c71d2601e5b81073459fd6347080b97df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:01:01 +0900 Subject: [PATCH 05/47] fix(analysis): enforce longitudinal CWC temporal and artifact contracts --- .../src/longitudinal_cwc_artifact.rs | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 643d0ecd2..37ae43d84 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -152,16 +152,27 @@ impl LongitudinalCwcArtifact { } fn validate(&self) -> Result<(), AnalysisEngineError> { + let max_rows = u64::try_from(MAX_EVIDENCE_UNITS) + .map_err(|_| AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; + let total_rows = self + .row_count + .checked_add(self.excluded_after_cutoff_count) + .ok_or(AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; + let expected_contextual_effect = self.between_slope - self.within_slope; if self.schema_version != LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION || !valid_identifier(&self.run_id) || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() || self.row_count < 2 + || self.row_count > max_rows || self.cluster_count < 2 || self.cluster_count > self.row_count + || total_rows > max_rows || !self.within_slope.is_finite() || !self.between_slope.is_finite() || !self.contextual_effect.is_finite() + || !expected_contextual_effect.is_finite() + || self.contextual_effect.to_bits() != expected_contextual_effect.to_bits() || self.inference_status != LONGITUDINAL_CWC_INFERENCE_STATUS { return Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact); @@ -215,6 +226,15 @@ fn admit_scores_at_cutoff( }) } +fn require_causal_refusal( + result: Result<(), PsychometricError>, +) -> Result<(), AnalysisEngineError> { + match result { + Err(PsychometricError::CausalUnderidentified) => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + #[expect( clippy::missing_panics_doc, reason = "bounded summary constants cannot fail" @@ -243,7 +263,9 @@ pub fn execute_longitudinal_cwc_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 != LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION || request.output_profile != LONGITUDINAL_CWC_OUTPUT_PROFILE { @@ -252,7 +274,7 @@ pub fn execute_longitudinal_cwc_run( let eligible = admit_scores_at_cutoff(scores, knowledge_cutoff)?; let slopes = recover_cluster_mean_within_between_slopes(&eligible.scores)?; - let _ = claim_causal_effect(CausalHeuristic::TemporalPrecedence); + require_causal_refusal(claim_causal_effect(CausalHeuristic::TemporalPrecedence))?; let mut clusters = std::collections::BTreeSet::new(); for score in &eligible.scores { @@ -276,13 +298,8 @@ pub fn execute_longitudinal_cwc_run( inference_status: LONGITUDINAL_CWC_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "longitudinal_cwc", - row_count, - 3, - LONGITUDINAL_CWC_INFERENCE_STATUS, - ) - .expect("bounded longitudinal CWC summary constants are valid"); + let summary = AnalysisResultSummary::new("longitudinal_cwc", row_count, 3, "validated") + .expect("bounded longitudinal CWC summary constants are valid"); let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, @@ -302,9 +319,10 @@ pub fn execute_longitudinal_cwc_run( mod tests { use super::{ LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, - LONGITUDINAL_CWC_INFERENCE_STATUS, LongitudinalCwcArtifact, + LONGITUDINAL_CWC_INFERENCE_STATUS, LongitudinalCwcArtifact, require_causal_refusal, }; use crate::AnalysisEngineError; + use psychometric_core::PsychometricError; fn artifact() -> LongitudinalCwcArtifact { LongitudinalCwcArtifact { @@ -404,6 +422,11 @@ mod tests { value.contextual_effect = f64::NEG_INFINITY; value }, + { + let mut value = artifact.clone(); + value.contextual_effect = 0.0; + value + }, { let mut value = artifact.clone(); value.inference_status.clear(); @@ -414,4 +437,20 @@ mod tests { assert_invalid(&invalid); } } + + #[test] + fn causal_refusal_contract_fails_closed_on_provider_drift() { + assert_eq!( + require_causal_refusal(Err(PsychometricError::CausalUnderidentified)), + Ok(()) + ); + assert_eq!( + require_causal_refusal(Ok(())), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_causal_refusal(Err(PsychometricError::InvalidNumericInput)), + Err(AnalysisEngineError::InvalidEvidence) + ); + } } From 99dbf5ea2a3876575f3f52557e36d903d6a05cf4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:01:32 +0900 Subject: [PATCH 06/47] test(analysis): require longitudinal CWC row snapshot provenance --- .../longitudinal_cwc_review_regressions.rs | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 11e9628db..82bd84c70 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -9,6 +9,8 @@ use analysis_engine::{ use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; +const SNAPSHOT_ID: &str = "snapshot-longitudinal-cwc"; + fn available(stamp: &str) -> AvailableTime { AvailableTime::parse_rfc3339(stamp).expect("available") } @@ -22,19 +24,36 @@ fn request(cutoff: &str) -> AnalysisRunRequest { contract_version: 1, idempotency_key: "longitudinal-cwc-review-regression".into(), tenant_workspace_id: "tenant-workspace".into(), - snapshot_id: "snapshot-longitudinal-cwc".into(), + snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: cutoff.into(), model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), } } +fn row( + snapshot_id: &str, + cluster_key: u64, + predictor: f64, + outcome: f64, + available_time: &str, +) -> LongitudinalClusterScore { + LongitudinalClusterScore::new( + snapshot_id, + cluster_key, + predictor, + outcome, + available(available_time), + ) + .expect("row") +} + fn rows() -> Vec { vec![ - LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), - LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), - LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-07-01T00:00:00Z")).expect("r3"), - LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-07-01T00:00:00Z")).expect("r4"), + row(SNAPSHOT_ID, 1, 0.0, 2.0, "2026-07-01T00:00:00Z"), + row(SNAPSHOT_ID, 1, 2.0, 3.0, "2026-07-01T00:00:00Z"), + row(SNAPSHOT_ID, 2, 4.0, 10.0, "2026-07-01T00:00:00Z"), + row(SNAPSHOT_ID, 2, 6.0, 11.0, "2026-07-01T00:00:00Z"), ] } @@ -42,7 +61,7 @@ fn artifact() -> LongitudinalCwcArtifact { LongitudinalCwcArtifact { schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), run_id: "run-longitudinal-cwc".into(), - snapshot_id: "snapshot-longitudinal-cwc".into(), + snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), row_count: 4, cluster_count: 2, @@ -67,7 +86,7 @@ fn equivalent_cutoff_instants_bind_and_provider_status_stays_separate() { let execution = execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &rows(), "2026-08-02T00:00:00Z", @@ -89,6 +108,37 @@ fn equivalent_cutoff_instants_bind_and_provider_status_stays_separate() { ); } +#[test] +fn cross_snapshot_rows_fail_closed_before_scientific_composition() { + let request = request("2026-08-01T00:00:00Z"); + let accepted = AnalysisRunAccepted::new( + "run-longitudinal-cwc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + let mut mixed = rows(); + mixed.push(row( + "snapshot-other", + 3, + 8.0, + 20.0, + "2026-08-15T00:00:00Z", + )); + + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &mixed, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); +} + #[test] fn artifact_refuses_inconsistent_contextual_effect() { let mut tampered = artifact(); From 5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:02:06 +0900 Subject: [PATCH 07/47] fix(analysis): bind CWC rows to immutable snapshot provenance --- .../src/longitudinal_cwc_artifact.rs | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 37ae43d84..4c1a52f0b 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -27,8 +27,9 @@ pub const LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const LONGITUDINAL_CWC_INFERENCE_STATUS: &str = "composed_cwc_slopes_not_causal"; /// One already-mapped clustered score offered to a cutoff-safe CWC run. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct LongitudinalClusterScore { + snapshot_id: String, cluster_key: u64, predictor: f64, outcome: f64, @@ -36,22 +37,25 @@ pub struct LongitudinalClusterScore { } impl LongitudinalClusterScore { - /// Bind one clustered predictor–outcome pair to an availability clock. + /// Bind one clustered predictor–outcome pair to immutable snapshot and availability provenance. /// /// # Errors /// - /// Returns [`AnalysisEngineError::InvalidEvidence`] when either coordinate - /// is non-finite. + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the snapshot identifier is invalid or + /// either coordinate is non-finite. pub fn new( + snapshot_id: impl Into, cluster_key: u64, predictor: f64, outcome: f64, available_time: AvailableTime, ) -> Result { - if !predictor.is_finite() || !outcome.is_finite() { + let snapshot_id = snapshot_id.into(); + if !valid_identifier(&snapshot_id) || !predictor.is_finite() || !outcome.is_finite() { return Err(AnalysisEngineError::InvalidEvidence); } Ok(Self { + snapshot_id, cluster_key, predictor, outcome, @@ -59,27 +63,33 @@ impl LongitudinalClusterScore { }) } + /// Return the immutable source snapshot identity. + #[must_use] + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + /// Return the cluster identity. #[must_use] - pub const fn cluster_key(self) -> u64 { + pub const fn cluster_key(&self) -> u64 { self.cluster_key } /// Return the already-mapped predictor. #[must_use] - pub const fn predictor(self) -> f64 { + pub const fn predictor(&self) -> f64 { self.predictor } /// Return the already-mapped outcome. #[must_use] - pub const fn outcome(self) -> f64 { + pub const fn outcome(&self) -> f64 { self.outcome } /// Return the availability clock used for cutoff eligibility. #[must_use] - pub const fn available_time(self) -> AvailableTime { + pub const fn available_time(&self) -> AvailableTime { self.available_time } } @@ -197,6 +207,7 @@ struct EligibleCwcRows { fn admit_scores_at_cutoff( scores: &[LongitudinalClusterScore], + snapshot_id: &str, knowledge_cutoff: KnowledgeCutoff, ) -> Result { if scores.len() > MAX_EVIDENCE_UNITS { @@ -205,6 +216,9 @@ fn admit_scores_at_cutoff( let mut eligible = Vec::new(); let mut excluded_after_cutoff_count = 0_u64; for score in scores { + if score.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } if score.available_time.instant() <= knowledge_cutoff.instant() { eligible.push(ClusteredScore { cluster_key: score.cluster_key, @@ -241,9 +255,9 @@ fn require_causal_refusal( )] /// Execute cutoff-safe CWC within/between composition as one analysis-run profile. /// -/// The caller supplies already-mapped clustered coordinates. This executor does -/// not invent an ESEM/DSEM estimator, persist rows, or treat the recovered -/// slopes as a causal effect. +/// The caller supplies already-mapped clustered coordinates. Each row carries its immutable +/// source snapshot and availability provenance. This executor does not invent an ESEM/DSEM +/// estimator, persist rows, or treat the recovered slopes as a causal effect. /// /// # Errors /// @@ -272,7 +286,7 @@ pub fn execute_longitudinal_cwc_run( return Err(AnalysisEngineError::InvalidEvidence); } - let eligible = admit_scores_at_cutoff(scores, knowledge_cutoff)?; + let eligible = admit_scores_at_cutoff(scores, snapshot_id, knowledge_cutoff)?; let slopes = recover_cluster_mean_within_between_slopes(&eligible.scores)?; require_causal_refusal(claim_causal_effect(CausalHeuristic::TemporalPrecedence))?; From 90eb364334175e1b0f3924eaf278f2bc5c26efa1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:02:29 +0900 Subject: [PATCH 08/47] test(analysis): migrate longitudinal CWC fixtures to snapshot provenance --- .../longitudinal_cwc_execution_contract.rs | 91 +++++++++++++------ 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs index 96f7eb75d..b0ccf082c 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs @@ -9,6 +9,8 @@ use psychometric_core::PsychometricError; use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +const SNAPSHOT_ID: &str = "snapshot-longitudinal-cwc"; + fn available(stamp: &str) -> AvailableTime { AvailableTime::parse_rfc3339(stamp).expect("available") } @@ -17,12 +19,28 @@ fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } +fn score( + cluster_key: u64, + predictor: f64, + outcome: f64, + available_at: &str, +) -> LongitudinalClusterScore { + LongitudinalClusterScore::new( + SNAPSHOT_ID, + cluster_key, + predictor, + outcome, + available(available_at), + ) + .expect("score") +} + fn noiseless_rows() -> Vec { vec![ - LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), - LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), - LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-07-01T00:00:00Z")).expect("r3"), - LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-07-01T00:00:00Z")).expect("r4"), + score(1, 0.0, 2.0, "2026-07-01T00:00:00Z"), + score(1, 2.0, 3.0, "2026-07-01T00:00:00Z"), + score(2, 4.0, 10.0, "2026-07-01T00:00:00Z"), + score(2, 6.0, 11.0, "2026-07-01T00:00:00Z"), ] } @@ -31,7 +49,7 @@ fn request() -> AnalysisRunRequest { contract_version: 1, idempotency_key: "longitudinal-cwc-idem".into(), tenant_workspace_id: "tenant-workspace".into(), - snapshot_id: "snapshot-longitudinal-cwc".into(), + snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), @@ -51,7 +69,7 @@ fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { let execution = execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &rows, "2026-08-02T00:00:00Z", @@ -84,6 +102,7 @@ fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { execution.terminal_result.result_schema_version.as_deref(), Some(LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION) ); + assert_eq!(rows[0].snapshot_id(), SNAPSHOT_ID); assert_eq!(rows[0].cluster_key(), 1); assert!((rows[0].predictor() - 0.0).abs() < f64::EPSILON); assert!((rows[0].outcome() - 2.0).abs() < f64::EPSILON); @@ -95,14 +114,11 @@ fn execution_excludes_rows_unavailable_at_the_request_cutoff() { let request = request(); let accepted = accepted(&request); let mut rows = noiseless_rows(); - rows.push( - LongitudinalClusterScore::new(3, 8.0, 20.0, available("2026-08-15T00:00:00Z")) - .expect("late"), - ); + rows.push(score(3, 8.0, 20.0, "2026-08-15T00:00:00Z")); let execution = execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &rows, "2026-08-02T00:00:00Z", @@ -152,7 +168,7 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { execute_longitudinal_cwc_run( &invalid_request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &rows, "2026-08-02T00:00:00Z", @@ -167,11 +183,33 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { let request = request(); let accepted = accepted(&request); assert_eq!( - LongitudinalClusterScore::new(1, f64::NAN, 1.0, available("2026-07-01T00:00:00Z")), + LongitudinalClusterScore::new( + SNAPSHOT_ID, + 1, + f64::NAN, + 1.0, + available("2026-07-01T00:00:00Z") + ), Err(AnalysisEngineError::InvalidEvidence) ); assert_eq!( - LongitudinalClusterScore::new(1, 1.0, f64::NAN, available("2026-07-01T00:00:00Z")), + LongitudinalClusterScore::new( + SNAPSHOT_ID, + 1, + 1.0, + f64::NAN, + available("2026-07-01T00:00:00Z") + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + LongitudinalClusterScore::new( + "", + 1, + 1.0, + 1.0, + available("2026-07-01T00:00:00Z") + ), Err(AnalysisEngineError::InvalidEvidence) ); @@ -182,7 +220,7 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { execute_longitudinal_cwc_run( &early_request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, too_early, &noiseless_rows(), "2026-08-02T00:00:00Z", @@ -193,16 +231,16 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { ); let late_cluster_two = vec![ - LongitudinalClusterScore::new(1, 0.0, 2.0, available("2026-07-01T00:00:00Z")).expect("r1"), - LongitudinalClusterScore::new(1, 2.0, 3.0, available("2026-07-01T00:00:00Z")).expect("r2"), - LongitudinalClusterScore::new(2, 4.0, 10.0, available("2026-08-15T00:00:00Z")).expect("r3"), - LongitudinalClusterScore::new(2, 6.0, 11.0, available("2026-08-15T00:00:00Z")).expect("r4"), + score(1, 0.0, 2.0, "2026-07-01T00:00:00Z"), + score(1, 2.0, 3.0, "2026-07-01T00:00:00Z"), + score(2, 4.0, 10.0, "2026-08-15T00:00:00Z"), + score(2, 6.0, 11.0, "2026-08-15T00:00:00Z"), ]; assert_eq!( execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &late_cluster_two, "2026-08-02T00:00:00Z", @@ -218,7 +256,7 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { execute_longitudinal_cwc_run( &request, &wrong_receipt, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &noiseless_rows(), "2026-08-02T00:00:00Z", @@ -227,17 +265,12 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { AnalysisEngineError::Api(tepp_api::ApiError::InvalidWirePayload) ); - let oversized = - vec![ - LongitudinalClusterScore::new(1, 0.0, 1.0, available("2026-07-01T00:00:00Z")) - .expect("row"); - MAX_EVIDENCE_UNITS + 1 - ]; + let oversized = vec![score(1, 0.0, 1.0, "2026-07-01T00:00:00Z"); MAX_EVIDENCE_UNITS + 1]; assert_eq!( execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &oversized, "2026-08-02T00:00:00Z", @@ -254,7 +287,7 @@ fn execution_refuses_invalid_completion_time() { execute_longitudinal_cwc_run( &request, &accepted, - "snapshot-longitudinal-cwc", + SNAPSHOT_ID, cutoff(), &noiseless_rows(), "invalid", From 9fd538414ed8dc78a8fa64c02126285748e354ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:03:21 +0900 Subject: [PATCH 09/47] docs(adr): keep longitudinal CWC decision proposed until landing --- .../adr/0033-longitudinal-cwc-analysis-run.md | 76 +++++++++---------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/docs/adr/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md index f515e58aa..1772c693d 100644 --- a/docs/adr/0033-longitudinal-cwc-analysis-run.md +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -1,6 +1,6 @@ # ADR 0033 — Longitudinal CWC composition 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 0005 (ESEM/DSEM interpretation) and ADR 0022 (cutoff-safe analysis-run execution). @@ -9,65 +9,59 @@ ## Context -Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered -within/between OLS and the CWC contextual effect inside `psychometric_core`. -Operators still cannot request that composition as a digest-bound analysis-run -output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169). -A second Driver p.16 `std`-family restore would not close this operator-visible -gap. +Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered within/between OLS and the CWC contextual effect inside `psychometric_core`. Operators still cannot request that composition as a digest-bound analysis-run output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169), and branch-local implementation does not make this ADR protected-main authority. + +The first profile implementation also exposed four contract defects during review: equivalent RFC 3339 spellings of one cutoff instant were rejected, completed artifacts did not enforce `contextual_effect = between_slope - within_slope`, artifact evidence counts could exceed the executor population bound, and the causal-refusal provider result was discarded. A fresh scientific review additionally found that rows carried availability but no immutable source snapshot identity, so a caller could attribute another snapshot's coordinates to the requested snapshot. ## Decision -Add the `longitudinal_cwc_v1` analysis-run output profile to `analysis_engine`. +Add the `longitudinal_cwc_v1` analysis-run output profile to `analysis_engine`, while keeping the reusable numerical estimator in `psychometric_core`. + The executor: -- consumes already-mapped clustered predictor/outcome coordinates plus - `available_time`; -- excludes rows whose availability is later than the request `knowledge_cutoff`; -- invokes `recover_cluster_mean_within_between_slopes` without reimplementing - CWC; -- invokes `claim_causal_effect` so temporal precedence cannot promote the - slopes to a causal estimand; -- emits a canonical SHA-256-digested `tepp.longitudinal_cwc.v1` artifact with - row/cluster counts, excluded-after-cutoff count, within/between/contextual - slopes, and inference status `composed_cwc_slopes_not_causal`; -- does not invent an ESEM/DSEM sampler, persist rows, or claim strong - invariance or Rubin pooling. - -This is two-level OLS composition, not DSEM, not RI-CLPM, and not a -random-effects sampler. +- requires every clustered score to carry immutable `snapshot_id` and `AvailableTime` provenance; +- rejects cross-snapshot evidence before scientific composition and excludes same-snapshot rows whose availability is later than the requested knowledge cutoff; +- binds request and executor cutoffs by parsed `KnowledgeCutoff::instant()` equality rather than RFC 3339 text; +- preserves the raw `MAX_EVIDENCE_UNITS` admission ceiling and validates completed row/exclusion counts against the same executable population bound; +- invokes `recover_cluster_mean_within_between_slopes` without reimplementing CWC arithmetic; +- requires the exact `claim_causal_effect(CausalHeuristic::TemporalPrecedence)` refusal and fails closed if that provider contract drifts; +- validates the contextual-effect identity exactly against the recovered within/between slopes; +- emits terminal provider status `validated` separately from artifact inference status `composed_cwc_slopes_not_causal`; +- emits canonical SHA-256-bound `tepp.longitudinal_cwc.v1` output and does not persist raw rows. + +This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, or causal identification. ## Alternatives considered -1. Restore another Driver p.16 standardised matrix — rejected because those - recoveries are already a live micro-PR family and do not bind composition to - an analysis run. -2. Put CWC execution into `tepp_api` — rejected because transport contracts and - scientific composition would become one service boundary. -3. Bind the existing `psychometric_core` CWC recovery to ADR 0022's analysis-run - profile — accepted. +1. Restore another Driver p.16 standardised matrix — rejected because those recoveries do not bind composition to an analysis run. +2. Put CWC execution into `tepp_api` — rejected because transport contracts and scientific composition would become one service boundary. +3. Trust the run-level snapshot label without per-row provenance — rejected because it cannot prove that supplied coordinates belong to the requested immutable snapshot. +4. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. +5. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. + +## Scientific acceptance boundary + +A noiseless fixture and existing owner-level known-truth tests are regression evidence, not commercial scientific acceptance for this profile. Issue #501 owns the remaining profile-level recovery evidence: repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; and leakage-safe temporal evaluation where availability changes over time. + +The profile must not be described as scientifically accepted or release-ready while #501 remains open without equivalent checked-in evidence. ## Consequences -Operators can request cutoff-safe within/between/contextual slopes as a -digest-bound terminal result. The artifact is not a causal effect, not an ESEM -fit, and not implemented-main until exact-head Checks and two independent -approvals land. +Operators can eventually request a historical, snapshot-bound within/between/contextual composition without allowing future evidence, another snapshot, or a provider-contract drift to silently change the scientific result. The artifact remains associational and explicitly separates provider validation from the scientific claim boundary. + +Shared ADR index, TRACEABILITY and product-gap currentization belong to the canonical documentation/consolidation lane. This branch-local ADR remains `Proposed` until the implementation is inherited by protected-main authority and its merge/release gates are satisfied. ## Verification ```text -cargo fmt -p analysis_engine -- --check +cargo fmt --all -- --check cargo test -p analysis_engine cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py ``` -Known-truth noiseless CWC recovers within `0.5`, between `2.0`, contextual -`1.5`. Cutoff exclusion, snapshot/profile mismatch, empty eligibility, and -single-cluster remainder fail closed. +Regression contracts cover equivalent cutoff instants, snapshot provenance, cross-snapshot refusal, impossible artifact counts, contextual-effect tampering, provider/domain status separation and causal-refusal fail-closed behavior. Scientific acceptance remains #501. ## Rollback and supersession -Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema -migration is introduced. Supersede only with an ADR that keeps CWC distinct -from between-cluster effects and from causal identification. +Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable snapshot/availability provenance, and retains leakage-safe historical replay semantics. From a0783f9ea66d4f498aaf586e5b6b96c025e6ecec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 13:03:36 +0900 Subject: [PATCH 10/47] docs(doctoring): record longitudinal CWC repair lineage --- .../longitudinal-cwc-analysis-run.md | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index 933fc84bb..ba62b6ac0 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -1,18 +1,41 @@ # Longitudinal CWC analysis-run bind -**Review date:** 2026-08-31 +**Review date:** 2026-09-14 **Active slice:** GAP-006 / issue #169 remaining operator-visible composition +**Scientific acceptance owner:** #501 -Protected main already recovers Enders and Tofighi (2007) CWC within/between -OLS in `psychometric_core`. This slice binds that recovery to -`analysis_engine` as a cutoff-safe analysis-run output: eligibility against -the request knowledge cutoff, digest-bound `tepp.longitudinal_cwc.v1`, and an -explicit refusal to treat the slopes as a causal effect. +Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. -This is not a new estimator, not a Driver p.16 `std` restore, not persistence, -and not implemented-main. +## Repaired application contract -## Evidence boundary +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, and reused the scientific inference label as terminal provider validation state. -Exact-head checks, independent review, and protected merge are required before -the profile can be promoted. +Current branch behavior is stricter: + +- every `LongitudinalClusterScore` carries immutable source `snapshot_id` plus `AvailableTime`; +- cross-snapshot rows fail closed before scientific composition; +- same-snapshot rows unavailable at the cutoff are censored before the CWC owner is called; +- equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; +- raw execution population and completed artifact row/exclusion counts share the `MAX_EVIDENCE_UNITS` envelope; +- `contextual_effect` must equal the exact `between_slope - within_slope` value produced by the owner contract; +- the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; +- terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. + +RED / repair lineage on this branch: + +- `6fd006e6d582c0a79cca7c007f7db4e8540409d1` adds failing review regressions for equivalent cutoffs, contextual-effect tampering and impossible artifact counts; +- `ec2bc21c71d2601e5b81073459fd6347080b97df` repairs those temporal/artifact/provider-status contracts and makes the causal-refusal result enforceable; +- `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` adds the row-level immutable snapshot-provenance RED; +- `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` binds snapshot provenance in production admission; +- `90eb364334175e1b0f3924eaf278f2bc5c26efa1` migrates the existing integration fixtures to the explicit provenance contract; +- ADR 0033 is `Proposed`, not protected-main `Accepted` authority. + +## Scientific evidence boundary + +Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, and leakage-safe temporal evaluation where availability changes over time. + +Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. + +## Merge boundary + +The PR remains Draft until exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. From e66a90f3c6be7c04ecc9a310baf955b16dbd6f76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:03:53 +0900 Subject: [PATCH 11/47] test(analysis): expose longitudinal CWC evidence replay --- ...itudinal_cwc_evidence_identity_contract.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs diff --git a/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs new file mode 100644 index 000000000..d3adfa2d5 --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs @@ -0,0 +1,157 @@ +//! Evidence-identity contracts for longitudinal CWC composition. + +use analysis_engine::{ + AnalysisEngineError, LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION, LONGITUDINAL_CWC_OUTPUT_PROFILE, + LongitudinalClusterScore, execute_longitudinal_cwc_run, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +const SNAPSHOT_ID: &str = "snapshot-longitudinal-cwc"; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn row( + evidence_id: &str, + cluster_key: u64, + predictor: f64, + outcome: f64, + available_at: &str, +) -> LongitudinalClusterScore { + LongitudinalClusterScore::new( + evidence_id, + SNAPSHOT_ID, + cluster_key, + predictor, + outcome, + available(available_at), + ) + .expect("row") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "longitudinal-cwc-evidence-identity".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: SNAPSHOT_ID.into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), + output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-longitudinal-cwc-identity", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn baseline_rows() -> Vec { + vec![ + row("evidence-1", 1, 0.0, 2.0, "2026-07-01T00:00:00Z"), + row("evidence-2", 1, 2.0, 3.0, "2026-07-01T00:00:00Z"), + row("evidence-3", 2, 4.0, 10.0, "2026-07-01T00:00:00Z"), + row("evidence-4", 2, 6.0, 11.0, "2026-07-01T00:00:00Z"), + ] +} + +#[test] +fn duplicate_evidence_identity_fails_closed_before_cwc_composition() { + let request = request(); + let accepted = accepted(&request); + let mut rows = baseline_rows(); + rows.push(row( + "evidence-2", + 1, + 9.0, + -7.0, + "2026-07-02T00:00:00Z", + )); + + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::DuplicateEvidence) + ); +} + +#[test] +fn duplicate_identity_after_cutoff_is_still_a_snapshot_contract_failure() { + let request = request(); + let accepted = accepted(&request); + let mut rows = baseline_rows(); + rows.push(row( + "evidence-2", + 3, + 100.0, + 100.0, + "2026-08-15T00:00:00Z", + )); + + assert_eq!( + execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::DuplicateEvidence) + ); +} + +#[test] +fn numerically_equal_rows_with_distinct_identity_remain_distinct_evidence() { + let request = request(); + let accepted = accepted(&request); + let mut rows = baseline_rows(); + rows.push(row( + "evidence-5", + 1, + 2.0, + 3.0, + "2026-07-01T00:00:00Z", + )); + + let execution = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ) + .expect("distinct evidence identity must not be tuple-deduplicated"); + + assert_eq!(execution.artifact.row_count, 5); + assert_eq!(rows[1].evidence_id(), "evidence-2"); + assert_eq!(rows[4].evidence_id(), "evidence-5"); +} + +#[test] +fn evidence_identity_is_bounded_by_the_existing_analysis_identifier_contract() { + assert_eq!( + LongitudinalClusterScore::new( + "", + SNAPSHOT_ID, + 1, + 0.0, + 1.0, + available("2026-07-01T00:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} From 138be1fb8ad3ae47a18d7e05645d2ca2830cd341 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:05:08 +0900 Subject: [PATCH 12/47] fix(analysis): reject replayed longitudinal CWC evidence --- .../src/longitudinal_cwc_artifact.rs | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 4c1a52f0b..75a14d375 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -6,6 +6,7 @@ use psychometric_core::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, @@ -29,6 +30,7 @@ const LONGITUDINAL_CWC_INFERENCE_STATUS: &str = "composed_cwc_slopes_not_causal" /// One already-mapped clustered score offered to a cutoff-safe CWC run. #[derive(Clone, Debug, PartialEq)] pub struct LongitudinalClusterScore { + evidence_id: String, snapshot_id: String, cluster_key: u64, predictor: f64, @@ -37,24 +39,31 @@ pub struct LongitudinalClusterScore { } impl LongitudinalClusterScore { - /// Bind one clustered predictor–outcome pair to immutable snapshot and availability provenance. + /// Bind one clustered predictor–outcome pair to immutable evidence, snapshot, and availability provenance. /// /// # Errors /// - /// Returns [`AnalysisEngineError::InvalidEvidence`] when the snapshot identifier is invalid or - /// either coordinate is non-finite. + /// Returns [`AnalysisEngineError::InvalidEvidence`] when either identity is invalid or either + /// coordinate is non-finite. pub fn new( + evidence_id: impl Into, snapshot_id: impl Into, cluster_key: u64, predictor: f64, outcome: f64, available_time: AvailableTime, ) -> Result { + let evidence_id = evidence_id.into(); let snapshot_id = snapshot_id.into(); - if !valid_identifier(&snapshot_id) || !predictor.is_finite() || !outcome.is_finite() { + if !valid_identifier(&evidence_id) + || !valid_identifier(&snapshot_id) + || !predictor.is_finite() + || !outcome.is_finite() + { return Err(AnalysisEngineError::InvalidEvidence); } Ok(Self { + evidence_id, snapshot_id, cluster_key, predictor, @@ -63,6 +72,12 @@ impl LongitudinalClusterScore { }) } + /// Return the opaque immutable evidence identity. + #[must_use] + pub fn evidence_id(&self) -> &str { + &self.evidence_id + } + /// Return the immutable source snapshot identity. #[must_use] pub fn snapshot_id(&self) -> &str { @@ -213,12 +228,16 @@ fn admit_scores_at_cutoff( if scores.len() > MAX_EVIDENCE_UNITS { return Err(AnalysisEngineError::LimitExceeded); } + let mut evidence_ids = BTreeSet::new(); let mut eligible = Vec::new(); let mut excluded_after_cutoff_count = 0_u64; for score in scores { if score.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } + if !evidence_ids.insert(score.evidence_id.as_str()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } if score.available_time.instant() <= knowledge_cutoff.instant() { eligible.push(ClusteredScore { cluster_key: score.cluster_key, @@ -256,13 +275,15 @@ fn require_causal_refusal( /// Execute cutoff-safe CWC within/between composition as one analysis-run profile. /// /// The caller supplies already-mapped clustered coordinates. Each row carries its immutable -/// source snapshot and availability provenance. This executor does not invent an ESEM/DSEM -/// estimator, persist rows, or treat the recovered slopes as a causal effect. +/// evidence identity, source snapshot, and availability provenance. Duplicate evidence is +/// rejected before cutoff filtering so replay cannot alter row weighting or scientific output. +/// This executor does not invent an ESEM/DSEM estimator, persist rows, or treat the recovered +/// slopes as a causal effect. /// /// # Errors /// -/// Returns a request/receipt/snapshot/cutoff/profile error, psychometric -/// recovery failure, or invalid artifact error. +/// Returns a request/receipt/snapshot/cutoff/profile error, duplicate-evidence refusal, +/// psychometric recovery failure, or invalid artifact error. pub fn execute_longitudinal_cwc_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -290,7 +311,7 @@ pub fn execute_longitudinal_cwc_run( let slopes = recover_cluster_mean_within_between_slopes(&eligible.scores)?; require_causal_refusal(claim_causal_effect(CausalHeuristic::TemporalPrecedence))?; - let mut clusters = std::collections::BTreeSet::new(); + let mut clusters = BTreeSet::new(); for score in &eligible.scores { clusters.insert(score.cluster_key); } From 66cffe0c88f2c8e54881f6018a776c62daaf788c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:05:51 +0900 Subject: [PATCH 13/47] test(analysis): bind CWC fixtures to evidence identity --- .../tests/longitudinal_cwc_execution_contract.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs index b0ccf082c..bf757821a 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs @@ -25,7 +25,13 @@ fn score( outcome: f64, available_at: &str, ) -> LongitudinalClusterScore { + let evidence_id = format!( + "evidence-{cluster_key}-{:016x}-{:016x}-{available_at}", + predictor.to_bits(), + outcome.to_bits() + ); LongitudinalClusterScore::new( + evidence_id, SNAPSHOT_ID, cluster_key, predictor, @@ -102,6 +108,7 @@ fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { execution.terminal_result.result_schema_version.as_deref(), Some(LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION) ); + assert!(!rows[0].evidence_id().is_empty()); assert_eq!(rows[0].snapshot_id(), SNAPSHOT_ID); assert_eq!(rows[0].cluster_key(), 1); assert!((rows[0].predictor() - 0.0).abs() < f64::EPSILON); @@ -184,6 +191,7 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { let accepted = accepted(&request); assert_eq!( LongitudinalClusterScore::new( + "evidence-invalid-predictor", SNAPSHOT_ID, 1, f64::NAN, @@ -194,6 +202,7 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { ); assert_eq!( LongitudinalClusterScore::new( + "evidence-invalid-outcome", SNAPSHOT_ID, 1, 1.0, @@ -204,6 +213,7 @@ fn execution_refuses_empty_cutoff_one_cluster_and_receipt_mismatch() { ); assert_eq!( LongitudinalClusterScore::new( + "evidence-invalid-snapshot", "", 1, 1.0, From 418222f232dfc1eb8d6fa27d841e2302148ccf55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:06:17 +0900 Subject: [PATCH 14/47] test(analysis): retain row identity in CWC regressions --- .../tests/longitudinal_cwc_review_regressions.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 82bd84c70..a26d7678d 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -38,7 +38,13 @@ fn row( outcome: f64, available_time: &str, ) -> LongitudinalClusterScore { + let evidence_id = format!( + "evidence-{snapshot_id}-{cluster_key}-{:016x}-{:016x}-{available_time}", + predictor.to_bits(), + outcome.to_bits() + ); LongitudinalClusterScore::new( + evidence_id, snapshot_id, cluster_key, predictor, From 104e2b1620ee0b7d42d1990d1fa14caaf5da46af Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:08:49 +0900 Subject: [PATCH 15/47] docs(analysis): doctor CWC evidence identity boundary --- .../longitudinal-cwc-analysis-run.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index ba62b6ac0..6f9c01d86 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -1,18 +1,22 @@ # Longitudinal CWC analysis-run bind -**Review date:** 2026-09-14 +**Review date:** 2026-09-19 **Active slice:** GAP-006 / issue #169 remaining operator-visible composition **Scientific acceptance owner:** #501 +**Evidence-identity integrity owner:** #592 Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, and reused the scientific inference label as terminal provider validation state. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, and dropped the opaque evidence identity that the protected-main analysis corpus uses to prevent replay/pseudo-replication. Current branch behavior is stricter: -- every `LongitudinalClusterScore` carries immutable source `snapshot_id` plus `AvailableTime`; +- every `LongitudinalClusterScore` carries an opaque immutable `evidence_id`, source `snapshot_id`, and `AvailableTime`; +- `evidence_id` and `snapshot_id` use the existing bounded analysis identifier contract; +- repeated evidence identity fails closed with `AnalysisEngineError::DuplicateEvidence` before cutoff filtering or scientific composition, including a duplicate whose second appearance is unavailable at the historical cutoff; +- numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; - cross-snapshot rows fail closed before scientific composition; - same-snapshot rows unavailable at the cutoff are censored before the CWC owner is called; - equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; @@ -21,6 +25,8 @@ Current branch behavior is stricter: - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. +The evidence-identity rule is not deduplication for convenience. Replaying one row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. + RED / repair lineage on this branch: - `6fd006e6d582c0a79cca7c007f7db4e8540409d1` adds failing review regressions for equivalent cutoffs, contextual-effect tampering and impossible artifact counts; @@ -28,13 +34,16 @@ RED / repair lineage on this branch: - `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` adds the row-level immutable snapshot-provenance RED; - `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` binds snapshot provenance in production admission; - `90eb364334175e1b0f3924eaf278f2bc5c26efa1` migrates the existing integration fixtures to the explicit provenance contract; +- `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` adds the public #592 RED for explicit evidence identity, pre-cutoff and post-cutoff duplicate refusal, equal-value/distinct-identity admission, and identifier validation; +- `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and fail-closed duplicate detection without touching the `psychometric_core` arithmetic; +- `66cffe0c88f2c8e54881f6018a776c62daaf788c` and `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate the existing integration and review fixtures to the explicit identity contract; - ADR 0033 is `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary -Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, and leakage-safe temporal evaluation where availability changes over time. +Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe temporal evaluation where availability changes over time. -Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. +#592 is a prerequisite integrity repair for that evidence population, not a substitute for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. ## Merge boundary From bb8cb21ac40fc9f1a2d86612e8cbaaac5ccecb67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:10:56 +0900 Subject: [PATCH 16/47] test(analysis): keep future replay outside historical identity census --- ...itudinal_cwc_evidence_identity_contract.rs | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs index d3adfa2d5..c1e0a5735 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs @@ -62,7 +62,7 @@ fn baseline_rows() -> Vec { } #[test] -fn duplicate_evidence_identity_fails_closed_before_cwc_composition() { +fn duplicate_visible_evidence_identity_fails_closed_before_cwc_composition() { let request = request(); let accepted = accepted(&request); let mut rows = baseline_rows(); @@ -88,9 +88,19 @@ fn duplicate_evidence_identity_fails_closed_before_cwc_composition() { } #[test] -fn duplicate_identity_after_cutoff_is_still_a_snapshot_contract_failure() { +fn future_unavailable_duplicate_identity_does_not_change_historical_replay() { let request = request(); let accepted = accepted(&request); + let baseline = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &baseline_rows(), + "2026-08-02T00:00:00Z", + ) + .expect("baseline"); + let mut rows = baseline_rows(); rows.push(row( "evidence-2", @@ -99,18 +109,18 @@ fn duplicate_identity_after_cutoff_is_still_a_snapshot_contract_failure() { 100.0, "2026-08-15T00:00:00Z", )); + let replay = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &rows, + "2026-08-02T00:00:00Z", + ) + .expect("future-unavailable replay must stay outside the historical census"); - assert_eq!( - execute_longitudinal_cwc_run( - &request, - &accepted, - SNAPSHOT_ID, - cutoff(), - &rows, - "2026-08-02T00:00:00Z", - ), - Err(AnalysisEngineError::DuplicateEvidence) - ); + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); } #[test] From 6b07a4eafa3c009e36299aeca6e87a5b45201d56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:11:31 +0900 Subject: [PATCH 17/47] fix(analysis): deduplicate only cutoff-visible CWC evidence --- .../src/longitudinal_cwc_artifact.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 75a14d375..2dcc4e6dd 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -235,10 +235,10 @@ fn admit_scores_at_cutoff( if score.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if !evidence_ids.insert(score.evidence_id.as_str()) { - return Err(AnalysisEngineError::DuplicateEvidence); - } if score.available_time.instant() <= knowledge_cutoff.instant() { + if !evidence_ids.insert(score.evidence_id.as_str()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } eligible.push(ClusteredScore { cluster_key: score.cluster_key, predictor: score.predictor, @@ -275,14 +275,15 @@ fn require_causal_refusal( /// Execute cutoff-safe CWC within/between composition as one analysis-run profile. /// /// The caller supplies already-mapped clustered coordinates. Each row carries its immutable -/// evidence identity, source snapshot, and availability provenance. Duplicate evidence is -/// rejected before cutoff filtering so replay cannot alter row weighting or scientific output. +/// evidence identity, source snapshot, and availability provenance. Future-unavailable rows are +/// censored before evidence-identity admission, so they cannot alter a historical replay; +/// duplicate identities among cutoff-visible evidence fail closed before scientific composition. /// This executor does not invent an ESEM/DSEM estimator, persist rows, or treat the recovered /// slopes as a causal effect. /// /// # Errors /// -/// Returns a request/receipt/snapshot/cutoff/profile error, duplicate-evidence refusal, +/// Returns a request/receipt/snapshot/cutoff/profile error, duplicate-visible-evidence refusal, /// psychometric recovery failure, or invalid artifact error. pub fn execute_longitudinal_cwc_run( request: &AnalysisRunRequest, From 63ac964e9f9c9e94f55a7b881e1ead1ec04a2495 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:12:18 +0900 Subject: [PATCH 18/47] docs(analysis): align CWC identity census with cutoff history --- .../longitudinal-cwc-analysis-run.md | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index 6f9c01d86..22b26f2f4 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -9,23 +9,25 @@ Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, and dropped the opaque evidence identity that the protected-main analysis corpus uses to prevent replay/pseudo-replication. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, and dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication. Current branch behavior is stricter: - every `LongitudinalClusterScore` carries an opaque immutable `evidence_id`, source `snapshot_id`, and `AvailableTime`; - `evidence_id` and `snapshot_id` use the existing bounded analysis identifier contract; -- repeated evidence identity fails closed with `AnalysisEngineError::DuplicateEvidence` before cutoff filtering or scientific composition, including a duplicate whose second appearance is unavailable at the historical cutoff; -- numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; +- raw input cardinality is bounded before filtering; - cross-snapshot rows fail closed before scientific composition; -- same-snapshot rows unavailable at the cutoff are censored before the CWC owner is called; +- rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before identity admission; +- repeated identity among cutoff-visible evidence fails closed with `AnalysisEngineError::DuplicateEvidence` before CWC composition; +- a future-unavailable row that reuses a visible identity does not perturb the historical artifact or terminal result, matching the surviving Analysis Run vehicle #416; +- numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; - equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; - raw execution population and completed artifact row/exclusion counts share the `MAX_EVIDENCE_UNITS` envelope; - `contextual_effect` must equal the exact `between_slope - within_slope` value produced by the owner contract; - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. -The evidence-identity rule is not deduplication for convenience. Replaying one row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. +The identity rule is not tuple deduplication. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. Conversely, admitting a future-unavailable duplicate into the historical identity census would leak later knowledge into an earlier replay. RED / repair lineage on this branch: @@ -34,9 +36,12 @@ RED / repair lineage on this branch: - `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` adds the row-level immutable snapshot-provenance RED; - `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` binds snapshot provenance in production admission; - `90eb364334175e1b0f3924eaf278f2bc5c26efa1` migrates the existing integration fixtures to the explicit provenance contract; -- `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` adds the public #592 RED for explicit evidence identity, pre-cutoff and post-cutoff duplicate refusal, equal-value/distinct-identity admission, and identifier validation; -- `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and fail-closed duplicate detection without touching the `psychometric_core` arithmetic; -- `66cffe0c88f2c8e54881f6018a776c62daaf788c` and `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate the existing integration and review fixtures to the explicit identity contract; +- `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` adds the public #592 RED for explicit evidence identity and cutoff-visible duplicate refusal, but initially over-constrains a future-unavailable duplicate; +- `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and an initial fail-closed duplicate check; +- `66cffe0c88f2c8e54881f6018a776c62daaf788c` and `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate existing fixtures; +- `104e2b1620ee0b7d42d1990d1fa14caaf5da46af` doctors that first repair state; +- `bb8cb21ac40fc9f1a2d86612e8cbaaac5ccecb67` adds the leakage-safe supplemental RED proving a future-unavailable duplicate cannot change an earlier run; +- `6b07a4eafa3c009e36299aeca6e87a5b45201d56` moves duplicate admission inside the cutoff-visible branch while retaining the raw cardinality and cross-snapshot guards; - ADR 0033 is `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary @@ -47,4 +52,4 @@ Existing known-truth CWC tests are useful regression evidence, but one noiseless ## Merge boundary -The PR remains Draft until exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. +The PR remains Draft until #592 survives the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. From 35a8f082b87c0f4e9fdd6e41f6f5fee4cd3f601b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:14:29 +0900 Subject: [PATCH 19/47] test(analysis): keep future-only rows out of historical CWC artifacts --- ...itudinal_cwc_evidence_identity_contract.rs | 72 ++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs index c1e0a5735..10f821b1d 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_evidence_identity_contract.rs @@ -61,6 +61,20 @@ fn baseline_rows() -> Vec { ] } +fn execute(rows: &[LongitudinalClusterScore]) -> analysis_engine::LongitudinalCwcExecution { + let request = request(); + let accepted = accepted(&request); + execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + rows, + "2026-08-02T00:00:00Z", + ) + .expect("execution") +} + #[test] fn duplicate_visible_evidence_identity_fails_closed_before_cwc_composition() { let request = request(); @@ -88,36 +102,36 @@ fn duplicate_visible_evidence_identity_fails_closed_before_cwc_composition() { } #[test] -fn future_unavailable_duplicate_identity_does_not_change_historical_replay() { - let request = request(); - let accepted = accepted(&request); - let baseline = execute_longitudinal_cwc_run( - &request, - &accepted, - SNAPSHOT_ID, - cutoff(), - &baseline_rows(), - "2026-08-02T00:00:00Z", - ) - .expect("baseline"); +fn future_unavailable_distinct_evidence_does_not_change_historical_replay() { + let baseline_rows = baseline_rows(); + let baseline = execute(&baseline_rows); + let mut replay_rows = baseline_rows; + replay_rows.push(row( + "evidence-future", + 3, + 100.0, + 100.0, + "2026-08-15T00:00:00Z", + )); + let replay = execute(&replay_rows); - let mut rows = baseline_rows(); - rows.push(row( + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} + +#[test] +fn future_unavailable_duplicate_identity_does_not_change_historical_replay() { + let baseline_rows = baseline_rows(); + let baseline = execute(&baseline_rows); + let mut replay_rows = baseline_rows; + replay_rows.push(row( "evidence-2", 3, 100.0, 100.0, "2026-08-15T00:00:00Z", )); - let replay = execute_longitudinal_cwc_run( - &request, - &accepted, - SNAPSHOT_ID, - cutoff(), - &rows, - "2026-08-02T00:00:00Z", - ) - .expect("future-unavailable replay must stay outside the historical census"); + let replay = execute(&replay_rows); assert_eq!(replay.artifact, baseline.artifact); assert_eq!(replay.terminal_result, baseline.terminal_result); @@ -125,8 +139,6 @@ fn future_unavailable_duplicate_identity_does_not_change_historical_replay() { #[test] fn numerically_equal_rows_with_distinct_identity_remain_distinct_evidence() { - let request = request(); - let accepted = accepted(&request); let mut rows = baseline_rows(); rows.push(row( "evidence-5", @@ -136,15 +148,7 @@ fn numerically_equal_rows_with_distinct_identity_remain_distinct_evidence() { "2026-07-01T00:00:00Z", )); - let execution = execute_longitudinal_cwc_run( - &request, - &accepted, - SNAPSHOT_ID, - cutoff(), - &rows, - "2026-08-02T00:00:00Z", - ) - .expect("distinct evidence identity must not be tuple-deduplicated"); + let execution = execute(&rows); assert_eq!(execution.artifact.row_count, 5); assert_eq!(rows[1].evidence_id(), "evidence-2"); From 28509b74a7949fcfc8b40f479b7cbabdc76b89d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:15:03 +0900 Subject: [PATCH 20/47] fix(analysis): remove future-only census from CWC artifact --- .../src/longitudinal_cwc_artifact.rs | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 2dcc4e6dd..4048b6904 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -121,12 +121,10 @@ pub struct LongitudinalCwcArtifact { pub snapshot_id: String, /// Historical evidence cutoff used by the composition. pub knowledge_cutoff: String, - /// Eligible clustered rows after cutoff. + /// Cutoff-visible clustered rows used by the scientific composition. pub row_count: u64, - /// Distinct clusters among eligible rows. + /// Distinct clusters among cutoff-visible rows. pub cluster_count: u64, - /// Rows excluded because availability was after the cutoff. - pub excluded_after_cutoff_count: u64, /// Within-cluster OLS slope after CWC. pub within_slope: f64, /// Between-cluster OLS slope of cluster means. @@ -179,10 +177,6 @@ impl LongitudinalCwcArtifact { fn validate(&self) -> Result<(), AnalysisEngineError> { let max_rows = u64::try_from(MAX_EVIDENCE_UNITS) .map_err(|_| AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; - let total_rows = self - .row_count - .checked_add(self.excluded_after_cutoff_count) - .ok_or(AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; let expected_contextual_effect = self.between_slope - self.within_slope; if self.schema_version != LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION || !valid_identifier(&self.run_id) @@ -192,7 +186,6 @@ impl LongitudinalCwcArtifact { || self.row_count > max_rows || self.cluster_count < 2 || self.cluster_count > self.row_count - || total_rows > max_rows || !self.within_slope.is_finite() || !self.between_slope.is_finite() || !self.contextual_effect.is_finite() @@ -215,22 +208,16 @@ pub struct LongitudinalCwcExecution { pub terminal_result: AnalysisRunTerminalResult, } -struct EligibleCwcRows { - scores: Vec, - excluded_after_cutoff_count: u64, -} - fn admit_scores_at_cutoff( scores: &[LongitudinalClusterScore], snapshot_id: &str, knowledge_cutoff: KnowledgeCutoff, -) -> Result { +) -> Result, AnalysisEngineError> { if scores.len() > MAX_EVIDENCE_UNITS { return Err(AnalysisEngineError::LimitExceeded); } let mut evidence_ids = BTreeSet::new(); let mut eligible = Vec::new(); - let mut excluded_after_cutoff_count = 0_u64; for score in scores { if score.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); @@ -244,8 +231,6 @@ fn admit_scores_at_cutoff( predictor: score.predictor, outcome: score.outcome, }); - } else { - excluded_after_cutoff_count += 1; } } if eligible.is_empty() { @@ -253,10 +238,7 @@ fn admit_scores_at_cutoff( PsychometricError::InvalidNumericInput, )); } - Ok(EligibleCwcRows { - scores: eligible, - excluded_after_cutoff_count, - }) + Ok(eligible) } fn require_causal_refusal( @@ -276,7 +258,7 @@ fn require_causal_refusal( /// /// The caller supplies already-mapped clustered coordinates. Each row carries its immutable /// evidence identity, source snapshot, and availability provenance. Future-unavailable rows are -/// censored before evidence-identity admission, so they cannot alter a historical replay; +/// removed before the historical identity/domain census and do not enter digest-bound output; /// duplicate identities among cutoff-visible evidence fail closed before scientific composition. /// This executor does not invent an ESEM/DSEM estimator, persist rows, or treat the recovered /// slopes as a causal effect. @@ -309,15 +291,15 @@ pub fn execute_longitudinal_cwc_run( } let eligible = admit_scores_at_cutoff(scores, snapshot_id, knowledge_cutoff)?; - let slopes = recover_cluster_mean_within_between_slopes(&eligible.scores)?; + let slopes = recover_cluster_mean_within_between_slopes(&eligible)?; require_causal_refusal(claim_causal_effect(CausalHeuristic::TemporalPrecedence))?; let mut clusters = BTreeSet::new(); - for score in &eligible.scores { + for score in &eligible { clusters.insert(score.cluster_key); } - let row_count = u64::try_from(eligible.scores.len()) - .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let row_count = + u64::try_from(eligible.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; let cluster_count = u64::try_from(clusters.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; let artifact = LongitudinalCwcArtifact { @@ -327,7 +309,6 @@ pub fn execute_longitudinal_cwc_run( knowledge_cutoff: knowledge_cutoff.to_rfc3339(), row_count, cluster_count, - excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, within_slope: slopes.within_slope, between_slope: slopes.between_slope, contextual_effect: slopes.contextual_effect, @@ -368,7 +349,6 @@ mod tests { knowledge_cutoff: "2026-08-01T00:00:00Z".into(), row_count: 4, cluster_count: 2, - excluded_after_cutoff_count: 0, within_slope: 0.5, between_slope: 2.0, contextual_effect: 1.5, From f1230ef23c51865aee6143d3cc7b1e4cb0db3240 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:15:38 +0900 Subject: [PATCH 21/47] test(analysis): make CWC artifact invariant to future-only rows --- .../longitudinal_cwc_execution_contract.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs index bf757821a..6258e2021 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_execution_contract.rs @@ -88,7 +88,6 @@ fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { ); assert_eq!(execution.artifact.row_count, 4); assert_eq!(execution.artifact.cluster_count, 2); - assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); assert!((execution.artifact.within_slope - 0.5).abs() < 1e-12); assert!((execution.artifact.between_slope - 2.0).abs() < 1e-12); assert!((execution.artifact.contextual_effect - 1.5).abs() < 1e-12); @@ -117,25 +116,34 @@ fn noiseless_cwc_emits_digest_bound_within_between_and_contextual() { } #[test] -fn execution_excludes_rows_unavailable_at_the_request_cutoff() { +fn execution_excludes_future_rows_without_changing_historical_output() { let request = request(); let accepted = accepted(&request); - let mut rows = noiseless_rows(); - rows.push(score(3, 8.0, 20.0, "2026-08-15T00:00:00Z")); - let execution = execute_longitudinal_cwc_run( + let baseline_rows = noiseless_rows(); + let baseline = execute_longitudinal_cwc_run( &request, &accepted, SNAPSHOT_ID, cutoff(), - &rows, + &baseline_rows, "2026-08-02T00:00:00Z", ) - .expect("execution"); - assert_eq!(execution.artifact.row_count, 4); - assert_eq!(execution.artifact.cluster_count, 2); - assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); - assert!((execution.artifact.within_slope - 0.5).abs() < 1e-12); - assert!((execution.artifact.between_slope - 2.0).abs() < 1e-12); + .expect("baseline"); + + let mut replay_rows = baseline_rows; + replay_rows.push(score(3, 8.0, 20.0, "2026-08-15T00:00:00Z")); + let replay = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &replay_rows, + "2026-08-02T00:00:00Z", + ) + .expect("replay"); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); } #[test] From 4befe868eaf0d2384358c8fffc59432c96f1874b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:16:02 +0900 Subject: [PATCH 22/47] test(analysis): remove future census from CWC artifact fixture --- .../tests/longitudinal_cwc_review_regressions.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index a26d7678d..884ac1fe4 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -71,7 +71,6 @@ fn artifact() -> LongitudinalCwcArtifact { knowledge_cutoff: "2026-08-01T00:00:00Z".into(), row_count: 4, cluster_count: 2, - excluded_after_cutoff_count: 0, within_slope: 0.5, between_slope: 2.0, contextual_effect: 1.5, @@ -156,7 +155,7 @@ fn artifact_refuses_inconsistent_contextual_effect() { } #[test] -fn artifact_refuses_counts_impossible_for_the_executor() { +fn artifact_refuses_visible_count_impossible_for_the_executor() { let mut oversized = artifact(); oversized.row_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("limit") + 1; oversized.cluster_count = 2; @@ -164,13 +163,4 @@ fn artifact_refuses_counts_impossible_for_the_executor() { oversized.to_json(), Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) ); - - let mut impossible_total = artifact(); - impossible_total.row_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("limit"); - impossible_total.cluster_count = 2; - impossible_total.excluded_after_cutoff_count = 1; - assert_eq!( - impossible_total.to_json(), - Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) - ); } From 98daaa73422f65fd8be9dfdb9e0cd0df50192617 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:16:29 +0900 Subject: [PATCH 23/47] docs(adr): bind CWC replay to cutoff-visible evidence --- .../adr/0033-longitudinal-cwc-analysis-run.md | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/adr/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md index 1772c693d..097eb91cd 100644 --- a/docs/adr/0033-longitudinal-cwc-analysis-run.md +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -2,7 +2,7 @@ **Decision status:** Proposed **Implementation maturity:** active-PR — composed on this branch; not implemented-main -**Date:** 2026-08-31 +**Date:** 2026-08-31; reviewed 2026-09-19 **Supersedes:** None; complements ADR 0005 (ESEM/DSEM interpretation) and ADR 0022 (cutoff-safe analysis-run execution). **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. @@ -11,7 +11,7 @@ Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered within/between OLS and the CWC contextual effect inside `psychometric_core`. Operators still cannot request that composition as a digest-bound analysis-run output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169), and branch-local implementation does not make this ADR protected-main authority. -The first profile implementation also exposed four contract defects during review: equivalent RFC 3339 spellings of one cutoff instant were rejected, completed artifacts did not enforce `contextual_effect = between_slope - within_slope`, artifact evidence counts could exceed the executor population bound, and the causal-refusal provider result was discarded. A fresh scientific review additionally found that rows carried availability but no immutable source snapshot identity, so a caller could attribute another snapshot's coordinates to the requested snapshot. +Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; and a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result. ## Decision @@ -19,10 +19,14 @@ Add the `longitudinal_cwc_v1` analysis-run output profile to `analysis_engine`, The executor: -- requires every clustered score to carry immutable `snapshot_id` and `AvailableTime` provenance; -- rejects cross-snapshot evidence before scientific composition and excludes same-snapshot rows whose availability is later than the requested knowledge cutoff; +- requires every clustered score to carry bounded opaque `evidence_id`, immutable `snapshot_id`, and `AvailableTime` provenance; +- preserves the raw `MAX_EVIDENCE_UNITS` operational admission ceiling; +- rejects cross-snapshot input as a provenance violation; +- excludes rows with `AvailableTime > knowledge_cutoff` before the historical evidence-identity/domain census; +- rejects duplicate `evidence_id` among cutoff-visible evidence so one observation cannot acquire extra scientific weight; +- does not infer identity from cluster/predictor/outcome/time tuples, so numerically equal observations with distinct identities remain distinct evidence; +- keeps future-unavailable evidence out of public historical counts, artifact digest, and terminal result; it does not emit an excluded-future counter; - binds request and executor cutoffs by parsed `KnowledgeCutoff::instant()` equality rather than RFC 3339 text; -- preserves the raw `MAX_EVIDENCE_UNITS` admission ceiling and validates completed row/exclusion counts against the same executable population bound; - invokes `recover_cluster_mean_within_between_slopes` without reimplementing CWC arithmetic; - requires the exact `claim_causal_effect(CausalHeuristic::TemporalPrecedence)` refusal and fails closed if that provider contract drifts; - validates the contextual-effect identity exactly against the recovered within/between slopes; @@ -35,19 +39,21 @@ This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, 1. Restore another Driver p.16 standardised matrix — rejected because those recoveries do not bind composition to an analysis run. 2. Put CWC execution into `tepp_api` — rejected because transport contracts and scientific composition would become one service boundary. -3. Trust the run-level snapshot label without per-row provenance — rejected because it cannot prove that supplied coordinates belong to the requested immutable snapshot. -4. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. -5. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. +3. Trust a run-level snapshot without per-row snapshot/evidence provenance — rejected because replay and cross-snapshot misattribution would be indistinguishable from legitimate scientific weight. +4. Deduplicate by predictor/outcome/cluster/time tuple — rejected because equal observed values do not imply the same evidence unit. +5. Count rows excluded after the historical cutoff in the public artifact — rejected because later corpus existence would change an earlier replay and its digest. +6. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. +7. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. ## Scientific acceptance boundary -A noiseless fixture and existing owner-level known-truth tests are regression evidence, not commercial scientific acceptance for this profile. Issue #501 owns the remaining profile-level recovery evidence: repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; and leakage-safe temporal evaluation where availability changes over time. +The identity and leakage repairs establish input/output integrity, not commercial scientific acceptance. Issue #501 owns repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; unequal follow-up/time-varying availability; and leakage-safe rolling-origin evaluation. The profile must not be described as scientifically accepted or release-ready while #501 remains open without equivalent checked-in evidence. ## Consequences -Operators can eventually request a historical, snapshot-bound within/between/contextual composition without allowing future evidence, another snapshot, or a provider-contract drift to silently change the scientific result. The artifact remains associational and explicitly separates provider validation from the scientific claim boundary. +A historical CWC run is invariant to evidence that was unavailable at its cutoff. Duplicate identities in the evidence population that was actually observable then fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes; future-only census information is not part of the digest-bound historical result. Shared ADR index, TRACEABILITY and product-gap currentization belong to the canonical documentation/consolidation lane. This branch-local ADR remains `Proposed` until the implementation is inherited by protected-main authority and its merge/release gates are satisfied. @@ -60,8 +66,8 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` -Regression contracts cover equivalent cutoff instants, snapshot provenance, cross-snapshot refusal, impossible artifact counts, contextual-effect tampering, provider/domain status separation and causal-refusal fail-closed behavior. Scientific acceptance remains #501. +Regression contracts cover equivalent cutoff instants, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, contextual-effect tampering, impossible visible counts, provider/domain status separation, and causal-refusal fail-closed behavior. Scientific acceptance remains #501. ## Rollback and supersession -Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable snapshot/availability provenance, and retains leakage-safe historical replay semantics. +Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable evidence/snapshot/availability provenance, and retains cutoff-visible historical replay invariance. From ac894bdc49ad622ecbe0c105cfb775299c2e6941 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 04:16:55 +0900 Subject: [PATCH 24/47] docs(analysis): doctor future-only CWC artifact leakage --- .../longitudinal-cwc-analysis-run.md | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index 22b26f2f4..fd877e98c 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -4,52 +4,53 @@ **Active slice:** GAP-006 / issue #169 remaining operator-visible composition **Scientific acceptance owner:** #501 **Evidence-identity integrity owner:** #592 +**Historical-artifact leakage owner:** #593 Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, and dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, and emitted `excluded_after_cutoff_count`, which allowed later-only corpus existence to alter an earlier artifact digest. Current branch behavior is stricter: - every `LongitudinalClusterScore` carries an opaque immutable `evidence_id`, source `snapshot_id`, and `AvailableTime`; - `evidence_id` and `snapshot_id` use the existing bounded analysis identifier contract; - raw input cardinality is bounded before filtering; -- cross-snapshot rows fail closed before scientific composition; +- cross-snapshot input fails closed as a provenance violation; - rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before identity admission; - repeated identity among cutoff-visible evidence fails closed with `AnalysisEngineError::DuplicateEvidence` before CWC composition; -- a future-unavailable row that reuses a visible identity does not perturb the historical artifact or terminal result, matching the surviving Analysis Run vehicle #416; +- future-unavailable rows, including rows reusing a visible identity, do not change the historical artifact or terminal result; +- public artifact counts are cutoff-visible `row_count` and `cluster_count`; no future-only exclusion counter is emitted; - numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; - equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; -- raw execution population and completed artifact row/exclusion counts share the `MAX_EVIDENCE_UNITS` envelope; - `contextual_effect` must equal the exact `between_slope - within_slope` value produced by the owner contract; - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. -The identity rule is not tuple deduplication. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. Conversely, admitting a future-unavailable duplicate into the historical identity census would leak later knowledge into an earlier replay. +The identity rule is not tuple deduplication. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. Conversely, counting or identity-admitting later-unavailable rows would leak future corpus state into an earlier replay. RED / repair lineage on this branch: -- `6fd006e6d582c0a79cca7c007f7db4e8540409d1` adds failing review regressions for equivalent cutoffs, contextual-effect tampering and impossible artifact counts; -- `ec2bc21c71d2601e5b81073459fd6347080b97df` repairs those temporal/artifact/provider-status contracts and makes the causal-refusal result enforceable; -- `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` adds the row-level immutable snapshot-provenance RED; -- `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` binds snapshot provenance in production admission; -- `90eb364334175e1b0f3924eaf278f2bc5c26efa1` migrates the existing integration fixtures to the explicit provenance contract; -- `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` adds the public #592 RED for explicit evidence identity and cutoff-visible duplicate refusal, but initially over-constrains a future-unavailable duplicate; -- `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and an initial fail-closed duplicate check; -- `66cffe0c88f2c8e54881f6018a776c62daaf788c` and `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate existing fixtures; -- `104e2b1620ee0b7d42d1990d1fa14caaf5da46af` doctors that first repair state; -- `bb8cb21ac40fc9f1a2d86612e8cbaaac5ccecb67` adds the leakage-safe supplemental RED proving a future-unavailable duplicate cannot change an earlier run; -- `6b07a4eafa3c009e36299aeca6e87a5b45201d56` moves duplicate admission inside the cutoff-visible branch while retaining the raw cardinality and cross-snapshot guards; -- ADR 0033 is `Proposed`, not protected-main `Accepted` authority. +- `6fd006e6d582c0a79cca7c007f7db4e8540409d1` → `ec2bc21c71d2601e5b81073459fd6347080b97df`: cutoff identity, artifact consistency, count bounds, provider/domain separation, causal-refusal enforcement; +- `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` → `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` → `90eb364334175e1b0f3924eaf278f2bc5c26efa1`: snapshot/availability provenance and fixture migration; +- `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` introduces #592 evidence identity and visible-duplicate/equal-value contracts but initially over-constrains a future-unavailable duplicate; +- `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and the first duplicate check; +- `66cffe0c88f2c8e54881f6018a776c62daaf788c` / `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate fixtures; +- `bb8cb21ac40fc9f1a2d86612e8cbaaac5ccecb67` adds the leakage-safe supplemental #592 RED; +- `6b07a4eafa3c009e36299aeca6e87a5b45201d56` moves duplicate admission inside the cutoff-visible branch; +- `35a8f082b87c0f4e9fdd6e41f6f5fee4cd3f601b` adds #593's distinct future-row historical-invariance RED and makes the future-only artifact leakage explicit; +- `28509b74a7949fcfc8b40f479b7cbabdc76b89d1` removes `excluded_after_cutoff_count` from the public artifact and internal result projection while keeping the raw cardinality guard; +- `f1230ef23c51865aee6143d3cc7b1e4cb0db3240` / `4befe868eaf0d2384358c8fffc59432c96f1874b` migrate execution/review regressions to the no-future-census artifact; +- `98daaa73422f65fd8be9dfdb9e0cd0df50192617` currentizes ADR 0033 on evidence identity and historical replay; +- ADR 0033 remains `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary -Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe temporal evaluation where availability changes over time. +Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe rolling-origin evaluation. -#592 is a prerequisite integrity repair for that evidence population, not a substitute for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. +#592 and #593 are prerequisites for an uncontaminated historical recovery population, not substitutes for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. ## Merge boundary -The PR remains Draft until #592 survives the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. +The PR remains Draft until #592/#593 survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. From 0ea8f6573d8519762e3ef9038ec4c4892c8e3a8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 07:09:13 +0900 Subject: [PATCH 25/47] test(analysis): require cutoff-before-snapshot CWC admission --- .../longitudinal_cwc_review_regressions.rs | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 884ac1fe4..39a304eba 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -63,6 +63,30 @@ fn rows() -> Vec { ] } +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-longitudinal-cwc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, + scores: &[LongitudinalClusterScore], +) -> analysis_engine::LongitudinalCwcExecution { + execute_longitudinal_cwc_run( + request, + &accepted(request), + SNAPSHOT_ID, + cutoff(), + scores, + "2026-08-02T00:00:00Z", + ) + .expect("execution") +} + fn artifact() -> LongitudinalCwcArtifact { LongitudinalCwcArtifact { schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), @@ -81,12 +105,7 @@ fn artifact() -> LongitudinalCwcArtifact { #[test] fn equivalent_cutoff_instants_bind_and_provider_status_stays_separate() { let request = request("2026-08-01T01:00:00+01:00"); - let accepted = AnalysisRunAccepted::new( - "run-longitudinal-cwc", - "accepted", - &request.idempotency_key, - ) - .expect("accepted"); + let accepted = accepted(&request); let execution = execute_longitudinal_cwc_run( &request, @@ -114,21 +133,16 @@ fn equivalent_cutoff_instants_bind_and_provider_status_stays_separate() { } #[test] -fn cross_snapshot_rows_fail_closed_before_scientific_composition() { +fn visible_cross_snapshot_rows_fail_closed_before_scientific_composition() { let request = request("2026-08-01T00:00:00Z"); - let accepted = AnalysisRunAccepted::new( - "run-longitudinal-cwc", - "accepted", - &request.idempotency_key, - ) - .expect("accepted"); + let accepted = accepted(&request); let mut mixed = rows(); mixed.push(row( "snapshot-other", 3, 8.0, 20.0, - "2026-08-15T00:00:00Z", + "2026-07-15T00:00:00Z", )); assert_eq!( @@ -144,6 +158,25 @@ fn cross_snapshot_rows_fail_closed_before_scientific_composition() { ); } +#[test] +fn future_unavailable_cross_snapshot_rows_do_not_change_historical_replay() { + let request = request("2026-08-01T00:00:00Z"); + let baseline_rows = rows(); + let baseline = execute(&request, &baseline_rows); + let mut replay_rows = baseline_rows; + replay_rows.push(row( + "snapshot-other", + 3, + 8.0, + 20.0, + "2026-08-15T00:00:00Z", + )); + let replay = execute(&request, &replay_rows); + + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} + #[test] fn artifact_refuses_inconsistent_contextual_effect() { let mut tampered = artifact(); From 07360220cf8e7018107b271dc8e6e2c2f49b0c58 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 07:09:49 +0900 Subject: [PATCH 26/47] fix(analysis): admit CWC availability before snapshot provenance --- .../src/longitudinal_cwc_artifact.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 4048b6904..dadd76eb6 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -219,19 +219,20 @@ fn admit_scores_at_cutoff( let mut evidence_ids = BTreeSet::new(); let mut eligible = Vec::new(); for score in scores { + if score.available_time.instant() > knowledge_cutoff.instant() { + continue; + } if score.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if score.available_time.instant() <= knowledge_cutoff.instant() { - if !evidence_ids.insert(score.evidence_id.as_str()) { - return Err(AnalysisEngineError::DuplicateEvidence); - } - eligible.push(ClusteredScore { - cluster_key: score.cluster_key, - predictor: score.predictor, - outcome: score.outcome, - }); + if !evidence_ids.insert(score.evidence_id.as_str()) { + return Err(AnalysisEngineError::DuplicateEvidence); } + eligible.push(ClusteredScore { + cluster_key: score.cluster_key, + predictor: score.predictor, + outcome: score.outcome, + }); } if eligible.is_empty() { return Err(AnalysisEngineError::Psychometric( From b4edede35ef26e7445fce94883de918b46c962f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 07:10:10 +0900 Subject: [PATCH 27/47] docs(adr): bind CWC snapshot provenance to visible evidence --- .../adr/0033-longitudinal-cwc-analysis-run.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/adr/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md index 097eb91cd..c41187a2e 100644 --- a/docs/adr/0033-longitudinal-cwc-analysis-run.md +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -11,7 +11,7 @@ Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered within/between OLS and the CWC contextual effect inside `psychometric_core`. Operators still cannot request that composition as a digest-bound analysis-run output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169), and branch-local implementation does not make this ADR protected-main authority. -Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; and a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result. +Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result; and snapshot provenance was checked before availability admission, so a future-only row from another snapshot could turn an earlier successful replay into `SnapshotMismatch`. ## Decision @@ -21,8 +21,8 @@ The executor: - requires every clustered score to carry bounded opaque `evidence_id`, immutable `snapshot_id`, and `AvailableTime` provenance; - preserves the raw `MAX_EVIDENCE_UNITS` operational admission ceiling; -- rejects cross-snapshot input as a provenance violation; -- excludes rows with `AvailableTime > knowledge_cutoff` before the historical evidence-identity/domain census; +- excludes rows with `AvailableTime > knowledge_cutoff` before snapshot, evidence-identity, or scientific-domain admission; +- rejects a cross-snapshot row as a provenance violation when that row is cutoff-visible; - rejects duplicate `evidence_id` among cutoff-visible evidence so one observation cannot acquire extra scientific weight; - does not infer identity from cluster/predictor/outcome/time tuples, so numerically equal observations with distinct identities remain distinct evidence; - keeps future-unavailable evidence out of public historical counts, artifact digest, and terminal result; it does not emit an excluded-future counter; @@ -40,10 +40,11 @@ This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, 1. Restore another Driver p.16 standardised matrix — rejected because those recoveries do not bind composition to an analysis run. 2. Put CWC execution into `tepp_api` — rejected because transport contracts and scientific composition would become one service boundary. 3. Trust a run-level snapshot without per-row snapshot/evidence provenance — rejected because replay and cross-snapshot misattribution would be indistinguishable from legitimate scientific weight. -4. Deduplicate by predictor/outcome/cluster/time tuple — rejected because equal observed values do not imply the same evidence unit. -5. Count rows excluded after the historical cutoff in the public artifact — rejected because later corpus existence would change an earlier replay and its digest. -6. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. -7. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. +4. Validate snapshot provenance before availability — rejected because a row that did not exist at the historical cutoff would then be able to alter that replay solely through future corpus state (#595). +5. Deduplicate by predictor/outcome/cluster/time tuple — rejected because equal observed values do not imply the same evidence unit. +6. Count rows excluded after the historical cutoff in the public artifact — rejected because later corpus existence would change an earlier replay and its digest. +7. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. +8. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. ## Scientific acceptance boundary @@ -53,7 +54,7 @@ The profile must not be described as scientifically accepted or release-ready wh ## Consequences -A historical CWC run is invariant to evidence that was unavailable at its cutoff. Duplicate identities in the evidence population that was actually observable then fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes; future-only census information is not part of the digest-bound historical result. +A historical CWC run is invariant to evidence that was unavailable at its cutoff, including a future-only row carrying another snapshot identity. Snapshot provenance still fails closed for rows in the evidence population that was actually observable then. Duplicate identities in that cutoff-visible population fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes; future-only census information is not part of the digest-bound historical result. Shared ADR index, TRACEABILITY and product-gap currentization belong to the canonical documentation/consolidation lane. This branch-local ADR remains `Proposed` until the implementation is inherited by protected-main authority and its merge/release gates are satisfied. @@ -66,7 +67,7 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` -Regression contracts cover equivalent cutoff instants, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, contextual-effect tampering, impossible visible counts, provider/domain status separation, and causal-refusal fail-closed behavior. Scientific acceptance remains #501. +Regression contracts cover equivalent cutoff instants, cutoff-before-snapshot admission, visible cross-snapshot refusal, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, contextual-effect tampering, impossible visible counts, provider/domain status separation, and causal-refusal fail-closed behavior. Scientific acceptance remains #501. ## Rollback and supersession From c9a99ff5116c1b974535498d98d6731066b371ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 07:10:26 +0900 Subject: [PATCH 28/47] docs(analysis): doctor future-only snapshot provenance --- docs/doctoring/longitudinal-cwc-analysis-run.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index fd877e98c..cfdf8592e 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -5,22 +5,23 @@ **Scientific acceptance owner:** #501 **Evidence-identity integrity owner:** #592 **Historical-artifact leakage owner:** #593 +**Cutoff-before-snapshot provenance owner:** #595 Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, and emitted `excluded_after_cutoff_count`, which allowed later-only corpus existence to alter an earlier artifact digest. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, emitted `excluded_after_cutoff_count` so later-only corpus existence altered an earlier artifact digest, and checked row snapshot provenance before availability admission so a future-only row from another snapshot could fail an otherwise identical historical replay. Current branch behavior is stricter: - every `LongitudinalClusterScore` carries an opaque immutable `evidence_id`, source `snapshot_id`, and `AvailableTime`; - `evidence_id` and `snapshot_id` use the existing bounded analysis identifier contract; - raw input cardinality is bounded before filtering; -- cross-snapshot input fails closed as a provenance violation; -- rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before identity admission; +- rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before snapshot, identity, or scientific-domain admission; +- cross-snapshot input fails closed when the row is cutoff-visible; - repeated identity among cutoff-visible evidence fails closed with `AnalysisEngineError::DuplicateEvidence` before CWC composition; -- future-unavailable rows, including rows reusing a visible identity, do not change the historical artifact or terminal result; +- future-unavailable rows, including rows reusing a visible identity or carrying another snapshot identity, do not change the historical artifact or terminal result; - public artifact counts are cutoff-visible `row_count` and `cluster_count`; no future-only exclusion counter is emitted; - numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; - equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; @@ -28,7 +29,7 @@ Current branch behavior is stricter: - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. -The identity rule is not tuple deduplication. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. Conversely, counting or identity-admitting later-unavailable rows would leak future corpus state into an earlier replay. +The snapshot and identity rules are both historical-population rules, not raw-corpus side channels. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. A cutoff-visible row from another snapshot similarly violates provenance. Conversely, validating or counting later-unavailable rows would leak future corpus state into an earlier replay. RED / repair lineage on this branch: @@ -43,14 +44,16 @@ RED / repair lineage on this branch: - `28509b74a7949fcfc8b40f479b7cbabdc76b89d1` removes `excluded_after_cutoff_count` from the public artifact and internal result projection while keeping the raw cardinality guard; - `f1230ef23c51865aee6143d3cc7b1e4cb0db3240` / `4befe868eaf0d2384358c8fffc59432c96f1874b` migrate execution/review regressions to the no-future-census artifact; - `98daaa73422f65fd8be9dfdb9e0cd0df50192617` currentizes ADR 0033 on evidence identity and historical replay; +- `0ea8f6573d8519762e3ef9038ec4c4892c8e3a8b` is the #595 public RED: cutoff-visible foreign-snapshot evidence still fails closed, while a future-unavailable foreign-snapshot row must leave artifact and terminal result unchanged; +- `07360220cf8e7018107b271dc8e6e2c2f49b0c58` is the #595 causal repair: availability admission now precedes snapshot provenance, without weakening visible snapshot refusal or the raw cardinality ceiling; - ADR 0033 remains `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe rolling-origin evaluation. -#592 and #593 are prerequisites for an uncontaminated historical recovery population, not substitutes for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. +#592, #593 and #595 are prerequisites for an uncontaminated historical recovery population, not substitutes for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. ## Merge boundary -The PR remains Draft until #592/#593 survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. +The PR remains Draft until #592/#593/#595 survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. From 2a05f4d38f5136d32ab0170249dd3c47cf95949e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 08:09:33 +0900 Subject: [PATCH 29/47] test(longitudinal): prove CWC evidence-order invariance --- .../longitudinal_cwc_permutation_contract.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 crates/analysis_engine/tests/longitudinal_cwc_permutation_contract.rs diff --git a/crates/analysis_engine/tests/longitudinal_cwc_permutation_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_permutation_contract.rs new file mode 100644 index 000000000..ef4bf0627 --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_cwc_permutation_contract.rs @@ -0,0 +1,112 @@ +//! Consumer RED for enumeration-order invariance of longitudinal CWC composition. +//! +//! The same cutoff-visible evidence population must produce the same digest-bound +//! artifact regardless of source row enumeration. Generic correctly-rounded +//! binary64 mean arithmetic belongs to the released fast-mlsirm owner contract; +//! this test deliberately does not implement a local summation workaround. + +use analysis_engine::{ + LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION, LONGITUDINAL_CWC_OUTPUT_PROFILE, + LongitudinalClusterScore, execute_longitudinal_cwc_run, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +const SNAPSHOT_ID: &str = "snapshot-cwc-permutation"; + +fn available() -> AvailableTime { + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "cwc-permutation-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: SNAPSHOT_ID.into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LONGITUDINAL_CWC_MODEL_CONTRACT_VERSION.into(), + output_profile: LONGITUDINAL_CWC_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-cwc-permutation", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn row( + evidence_id: &str, + cluster_key: u64, + predictor: f64, + outcome: f64, +) -> LongitudinalClusterScore { + LongitudinalClusterScore::new( + evidence_id, + SNAPSHOT_ID, + cluster_key, + predictor, + outcome, + available(), + ) + .expect("finite row") +} + +fn baseline_rows() -> Vec { + vec![ + row("c1-high", 1, 10_000_000_000_000_000.0, 5_000_000_000_000_000.0), + row("c1-tail", 1, 1.0, 0.5), + row("c1-low", 1, -10_000_000_000_000_000.0, -5_000_000_000_000_000.0), + row("c2-high", 2, 10_000_000_000_000_010.0, 5_000_000_000_000_020.0), + row("c2-tail", 2, 11.0, 20.5), + row("c2-low", 2, -9_999_999_999_999_990.0, -4_999_999_999_999_980.0), + ] +} + +fn permuted_rows() -> Vec { + vec![ + row("c1-high", 1, 10_000_000_000_000_000.0, 5_000_000_000_000_000.0), + row("c1-low", 1, -10_000_000_000_000_000.0, -5_000_000_000_000_000.0), + row("c1-tail", 1, 1.0, 0.5), + row("c2-high", 2, 10_000_000_000_000_010.0, 5_000_000_000_000_020.0), + row("c2-tail", 2, 11.0, 20.5), + row("c2-low", 2, -9_999_999_999_999_990.0, -4_999_999_999_999_980.0), + ] +} + +#[test] +fn same_evidence_population_is_bit_identical_under_row_permutation() { + let request = request(); + let accepted = accepted(&request); + let baseline = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &baseline_rows(), + "2026-08-02T00:00:00Z", + ) + .expect("baseline execution"); + let permuted = execute_longitudinal_cwc_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + &permuted_rows(), + "2026-08-02T00:00:00Z", + ) + .expect("permuted execution"); + + assert_eq!( + permuted.artifact, baseline.artifact, + "CWC artifact changed when only evidence enumeration order changed" + ); + assert_eq!( + permuted.terminal_result, baseline.terminal_result, + "digest-bound terminal result changed when only evidence enumeration order changed" + ); +} From 3ef70bdea934f786317fa1d074a59d3ba084613f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 11:08:32 +0900 Subject: [PATCH 30/47] chore(longitudinal): preserve analysis profile shared surfaces Carry #416 Cargo and shared documentation state forward while retaining the #592/#593/#595/#596 child deltas. Add only the production psychometric_core dependency required by the CWC adapter; leave lib.rs as the remaining explicit shared-source reconciliation. --- CHANGELOG.md | 6 +++++- Cargo.lock | 7 ++++++- crates/analysis_engine/Cargo.toml | 9 +++++++-- docs/TRACEABILITY.md | 6 +++++- docs/adr/README.md | 12 ++++++++++-- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed205339..2eb0c3aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,11 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] -- `analysis_engine` binds Enders and Tofighi (2007) CWC within/between/contextual OLS (`recover_cluster_mean_within_between_slopes`) to the `longitudinal_cwc_v1` analysis-run output profile. Rows unavailable at the request cutoff are excluded; the digest-bound `tepp.longitudinal_cwc.v1` artifact records the three slopes and refuses causal promotion. This is not a new ESEM/DSEM estimator, not a Driver p.16 `std` restore, and not persistence. +- **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. +- **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. +- **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. +- **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. +- **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. - Removed the repository-local hourly PR-maintenance caller now covered by the central required scheduler, retired stale workflow registrations, narrowed documentation triggers, keyed PR concurrency by fixed workflow name, repository, and pull-request number without cancelling non-PR runs, and combined line/branch coverage on one sequential runner while preserving both 100% gates and diagnostics. - `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/Cargo.lock b/Cargo.lock index 28a0f0cb8..84367638e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,14 +71,19 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" name = "analysis_engine" version = "0.2.0" dependencies = [ + "copy_identity", "corpus_split", + "episode_membership", "event_core", + "inferred_status", + "location_membership", "membership_core", - "psychometric_core", + "membership_target", "relation_graph", "serde", "serde_json", "sha2", + "subevent_containment", "temporal_core", "tepp_api", "topic_measurement", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index d1f55851c..a95d7eeb3 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,10 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +episode_membership = { path = "../episode_membership", version = "0.2.0" } +inferred_status = { path = "../inferred_status", version = "0.2.0" } +membership_target = { path = "../membership_target", version = "0.2.0" } +subevent_containment = { path = "../subevent_containment", version = "0.2.0" } psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } @@ -22,12 +26,13 @@ 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" } +corpus_split = { path = "../corpus_split", version = "0.2.0" } +location_membership = { path = "../location_membership", version = "0.2.0" } uuid.workspace = true [dev-dependencies] -corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } -psychometric_core = { path = "../psychometric_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } [lints] diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 249dad53b..7e08cb866 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,7 +58,11 @@ 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 | -| cutoff-safe longitudinal CWC composition | ADR 0005/0033; Enders & Tofighi (2007) | `analysis_engine` `longitudinal_cwc_v1` binds `psychometric_core` CWC within/between/contextual slopes to a digest-bound `tepp.longitudinal_cwc.v1` artifact; causal promotion refused; not ESEM/DSEM estimation | 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 | +| 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 | +| 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 | +| 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 `containment_recovery_rate` inspect metric, 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 | +| 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/README.md b/docs/adr/README.md index 569c9bce6..7b345f856 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,9 +28,13 @@ 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. | -| [0033](0033-longitudinal-cwc-analysis-run.md) | Longitudinal CWC composition as an analysis-run output profile | Accepted | active-PR | Binds Enders–Tofighi CWC within/between/contextual slopes to `longitudinal_cwc_v1`; cutoff-filters rows; refuses causal promotion. | +| [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. | +| [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. | +| [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. | +| [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. | @@ -139,9 +143,13 @@ 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. +- **location-membership analysis-run profile:** ADR 0066. +- **membership-target analysis-run profile:** ADR 0069. +- **episode-membership analysis-run profile:** ADR 0072. +- **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. -- **longitudinal CWC analysis-run output profile:** ADR 0033. ## Change and supersession rule From e71af50fb47fa49d351b951b5bbf5aadbebc3117 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 11:11:02 +0900 Subject: [PATCH 31/47] fix(longitudinal): restore surviving #416 tree after restack Repair the first reconciliation tree without rewriting history. Start from exact #416, then overlay only the surviving longitudinal CWC production adapter, contracts, ADR/doctoring, merged manifest, and merged analysis_engine root. This restores every #416-owned file while retaining #592/#593/#595/#596 deltas. --- DOCUMENTATION.md | 5 + .../src/copy_identity_artifact.rs | 418 +++++++++++++ .../src/episode_membership_artifact.rs | 423 +++++++++++++ .../src/inferred_status_artifact.rs | 503 ++++++++++++++++ .../src/location_membership_artifact.rs | 469 +++++++++++++++ .../src/membership_target_artifact.rs | 568 ++++++++++++++++++ .../src/subevent_containment_artifact.rs | 424 +++++++++++++ .../copy_identity_artifact_bound_contract.rs | 112 ++++ .../tests/copy_identity_execution_contract.rs | 242 ++++++++ .../tests/end_to_end_contract.rs | 30 + ...sode_membership_artifact_bound_contract.rs | 25 + ...de_membership_cutoff_duplicate_contract.rs | 88 +++ .../episode_membership_execution_contract.rs | 312 ++++++++++ ...ferred_status_cutoff_duplicate_contract.rs | 84 +++ .../inferred_status_execution_contract.rs | 262 ++++++++ ...tion_membership_artifact_bound_contract.rs | 35 ++ ...on_membership_cutoff_duplicate_contract.rs | 92 +++ ..._membership_cutoff_equivalence_contract.rs | 57 ++ .../location_membership_execution_contract.rs | 284 +++++++++ ...mbership_target_artifact_bound_contract.rs | 32 + .../membership_target_cutoff_contract.rs | 83 +++ .../membership_target_execution_contract.rs | 322 ++++++++++ ...ent_containment_artifact_bound_contract.rs | 25 + ...t_containment_cutoff_duplicate_contract.rs | 88 +++ ...subevent_containment_execution_contract.rs | 296 +++++++++ ...ubevent_containment_regression_contract.rs | 97 +++ ...rdised_manifest_variance_error_messages.rs | 25 + docs/adr/0058-copy-identity-analysis-run.md | 87 +++ .../0066-location-membership-analysis-run.md | 93 +++ .../0069-membership-target-analysis-run.md | 97 +++ .../0072-episode-membership-analysis-run.md | 97 +++ .../0074-subevent-containment-analysis-run.md | 108 ++++ docs/doctoring/copy-identity-analysis-run.md | 14 + .../episode-membership-analysis-run.md | 18 + .../doctoring/inferred-status-analysis-run.md | 19 + .../location-membership-analysis-run.md | 18 + .../membership-target-analysis-run.md | 16 + .../subevent-containment-analysis-run.md | 19 + ...est_analysis_terminal_validation_status.py | 23 + 39 files changed, 6010 insertions(+) create mode 100644 crates/analysis_engine/src/copy_identity_artifact.rs create mode 100644 crates/analysis_engine/src/episode_membership_artifact.rs create mode 100644 crates/analysis_engine/src/inferred_status_artifact.rs create mode 100644 crates/analysis_engine/src/location_membership_artifact.rs create mode 100644 crates/analysis_engine/src/membership_target_artifact.rs create mode 100644 crates/analysis_engine/src/subevent_containment_artifact.rs create mode 100644 crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs create mode 100644 crates/analysis_engine/tests/copy_identity_execution_contract.rs create mode 100644 crates/analysis_engine/tests/episode_membership_artifact_bound_contract.rs create mode 100644 crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs create mode 100644 crates/analysis_engine/tests/episode_membership_execution_contract.rs create mode 100644 crates/analysis_engine/tests/inferred_status_cutoff_duplicate_contract.rs create mode 100644 crates/analysis_engine/tests/inferred_status_execution_contract.rs create mode 100644 crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs create mode 100644 crates/analysis_engine/tests/location_membership_cutoff_duplicate_contract.rs create mode 100644 crates/analysis_engine/tests/location_membership_cutoff_equivalence_contract.rs create mode 100644 crates/analysis_engine/tests/location_membership_execution_contract.rs create mode 100644 crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs create mode 100644 crates/analysis_engine/tests/membership_target_cutoff_contract.rs create mode 100644 crates/analysis_engine/tests/membership_target_execution_contract.rs create mode 100644 crates/analysis_engine/tests/subevent_containment_artifact_bound_contract.rs create mode 100644 crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs create mode 100644 crates/analysis_engine/tests/subevent_containment_execution_contract.rs create mode 100644 crates/analysis_engine/tests/subevent_containment_regression_contract.rs create mode 100644 crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs create mode 100644 docs/adr/0058-copy-identity-analysis-run.md create mode 100644 docs/adr/0066-location-membership-analysis-run.md create mode 100644 docs/adr/0069-membership-target-analysis-run.md create mode 100644 docs/adr/0072-episode-membership-analysis-run.md create mode 100644 docs/adr/0074-subevent-containment-analysis-run.md create mode 100644 docs/doctoring/copy-identity-analysis-run.md create mode 100644 docs/doctoring/episode-membership-analysis-run.md create mode 100644 docs/doctoring/inferred-status-analysis-run.md create mode 100644 docs/doctoring/location-membership-analysis-run.md create mode 100644 docs/doctoring/membership-target-analysis-run.md create mode 100644 docs/doctoring/subevent-containment-analysis-run.md create mode 100644 tests/quality/test_analysis_terminal_validation_status.py diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..39f3634a2 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -71,6 +71,11 @@ 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) | +| Location-membership analysis-run doctoring | [`docs/doctoring/location-membership-analysis-run.md`](docs/doctoring/location-membership-analysis-run.md) | +| Episode-membership analysis-run doctoring | [`docs/doctoring/episode-membership-analysis-run.md`](docs/doctoring/episode-membership-analysis-run.md) | +| Subevent-containment analysis-run doctoring | [`docs/doctoring/subevent-containment-analysis-run.md`](docs/doctoring/subevent-containment-analysis-run.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/src/copy_identity_artifact.rs b/crates/analysis_engine/src/copy_identity_artifact.rs new file mode 100644 index 000000000..26468fe61 --- /dev/null +++ b/crates/analysis_engine/src/copy_identity_artifact.rs @@ -0,0 +1,418 @@ +//! 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 corpus_split::cutoff_eligible; +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 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 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"; + +/// One cutoff-admitted document with a closed copy-identity kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CopyIdentityDocument { + document_id: String, + kind: CopyKind, + available_time: AvailableTime, +} + +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, + available_time: AvailableTime, + ) -> Result { + let document_id = document_id.into(); + if !valid_identifier(&document_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + document_id, + kind, + available_time, + }) + } + + /// 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 + } + + /// 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. +#[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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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.document_count > MAX_EVIDENCE_UNITS as u64 + || 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, oversized corpus, 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); + } + 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 + { + 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; + 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 !cutoff_eligible(document.available_time(), &knowledge_cutoff) { + continue; + } + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + CopyKind::SourceDocument => { + 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 => { + #[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)?; + } + } + } + let document_count = + 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); + } + + 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()?; + #[rustfmt::skip] + 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 { + artifact, + terminal_result, + }) +} + +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, require_copy_result, + }; + use crate::AnalysisEngineError; + use copy_identity::CopyIdentityError; + + 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.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(); + value + }, + ]; + for invalid in invalid_artifacts { + 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/src/episode_membership_artifact.rs b/crates/analysis_engine/src/episode_membership_artifact.rs new file mode 100644 index 000000000..2c137d5f9 --- /dev/null +++ b/crates/analysis_engine/src/episode_membership_artifact.rs @@ -0,0 +1,423 @@ +//! 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 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"; + +/// 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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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.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 != 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); + } + 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 + { + 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, "validated")?; + 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 assignment.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + 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) + .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, classify_membership_result, + }; + use crate::AnalysisEngineError; + use episode_membership::EpisodeMembershipError; + + 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); + } + } + + #[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) + ); + } +} 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..926c9e5d2 --- /dev/null +++ b/crates/analysis_engine/src/inferred_status_artifact.rs @@ -0,0 +1,503 @@ +//! 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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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.evidence_count > MAX_EVIDENCE_UNITS as u64 + || 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); + } + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() + || 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, "validated")?; + 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 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)?; + refuse_inferred_as_transition(row.status()).map_err(map_inferred_status_error)?; + observed_count = increment(observed_count)?; + } + EvidenceStatus::Inferred => { + 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)?; + } + } + } + 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 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, map_inferred_status_error, + require_refusal, + }; + use crate::{AnalysisEngineError, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS}; + + 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 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(); + 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(); + 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/location_membership_artifact.rs b/crates/analysis_engine/src/location_membership_artifact.rs new file mode 100644 index 000000000..40b584140 --- /dev/null +++ b/crates/analysis_engine/src/location_membership_artifact.rs @@ -0,0 +1,469 @@ +//! 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::{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 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 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"; + +/// One cutoff-admitted membership treatment with a closed location kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocationMembershipDocument { + document_id: String, + kind: LocationKind, + available_time: AvailableTime, +} + +impl LocationMembershipDocument { + /// Construct a bounded location-membership document. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document + /// 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, + available_time, + }) + } + + /// 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 + } + + /// 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. +#[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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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.document_count > MAX_EVIDENCE_UNITS as u64 + || 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. +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); + } + 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 + { + 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; + 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 document.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + LocationKind::Location => { + 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; + } + LocationKind::EntityIdentity => { + entity_identity_count += 1; + } + LocationKind::LanguageChannel => { + language_channel_count += 1; + } + } + } + let document_count = + u64::try_from(seen.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 { + analysis_family: "location_membership".into(), + evidence_count: document_count, + statistic_count: 5, + validation_status: "validated".into(), + }; + 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 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, require_refusal, + }; + use crate::{AnalysisEngineError, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS}; + + 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 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(); + 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.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(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} 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..2ee5d3dd3 --- /dev/null +++ b/crates/analysis_engine/src/membership_target_artifact.rs @@ -0,0 +1,568 @@ +//! Digest-bound membership-target refusals as an analysis-run profile. + +use membership_target::{MembershipTargetError, MembershipTargetKind, refuse_collapsed_target}; +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 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 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"; + +/// One cutoff-admitted membership treatment with a closed target kind. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MembershipTargetDocument { + document_id: String, + kind: MembershipTargetKind, + available_time: AvailableTime, +} + +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, + available_time: AvailableTime, + ) -> Result { + let document_id = document_id.into(); + if !valid_identifier(&document_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + document_id, + kind, + available_time, + }) + } + + /// 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 + } + + /// 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. +#[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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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 + || self.document_count > MAX_EVIDENCE_UNITS as u64 + || 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, +} + +#[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. +/// 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, an oversized corpus, or +/// an 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); + } + 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 + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if documents.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + 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; + 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 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 + | MembershipTargetKind::Template + | MembershipTargetKind::Department + | MembershipTargetKind::OpportunityPool => { + 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)?; + } + TypedMembershipTargetKind::Episode => { + episode_count = increment(episode_count)?; + } + TypedMembershipTargetKind::Template => { + template_count = increment(template_count)?; + } + TypedMembershipTargetKind::Department => { + department_count = increment(department_count)?; + } + TypedMembershipTargetKind::OpportunityPool => { + opportunity_pool_count = increment(opportunity_pool_count)?; + } + } + } + MembershipTargetKind::Entity => { + require_target_identity(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Entity, + ))?; + entity_count = increment(entity_count)?; + } + MembershipTargetKind::Project => { + require_target_identity(refuse_collapsed_target( + document.kind(), + MembershipTargetKind::Project, + ))?; + project_count = increment(project_count)?; + } + } + } + + 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, "validated")?; + 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 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) +} + +#[cfg(test)] +mod tests { + use super::{ + MEMBERSHIP_TARGET_ARTIFACT_BYTE_LIMIT, MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION, + 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 { + 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); + } + } + + #[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) + ); + } +} 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..ea95f47b9 --- /dev/null +++ b/crates/analysis_engine/src/subevent_containment_artifact.rs @@ -0,0 +1,424 @@ +//! 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 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"; + +/// 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 or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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); + } + 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 + { + 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, "validated")?; + 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 assignment.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + 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) + .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, + classify_subevent_result, + }; + use crate::AnalysisEngineError; + use subevent_containment::SubeventContainmentError; + + 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); + } + } + + #[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) + ); + } +} 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..0065b5950 --- /dev/null +++ b/crates/analysis_engine/tests/copy_identity_artifact_bound_contract.rs @@ -0,0 +1,112 @@ +//! 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, +}; +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 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; + 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) + ); +} 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..693f13138 --- /dev/null +++ b/crates/analysis_engine/tests/copy_identity_execution_contract.rs @@ -0,0 +1,242 @@ +//! 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::{AvailableTime, 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 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![ + document("source-a", CopyKind::SourceDocument), + document("copy-b", CopyKind::TemplateCopy), + document("copy-c", CopyKind::TemplateCopy), + ] +} + +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 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(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let sources_only = vec![ + document("source-a", CopyKind::SourceDocument), + document("source-b", CopyKind::SourceDocument), + ]; + assert_eq!( + execute(&request, &sources_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let copies_only = vec![ + document("copy-a", CopyKind::TemplateCopy), + document("copy-b", CopyKind::TemplateCopy), + ]; + assert_eq!( + execute(&request, &copies_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + document("same", CopyKind::SourceDocument), + document("same", CopyKind::TemplateCopy), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + CopyIdentityDocument::new( + "", + CopyKind::SourceDocument, + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn future_duplicate_identity_cannot_change_historical_cutoff_result() { + let request = request(); + let visible = mixed_documents(); + let baseline = execute(&request, &visible).expect("historical baseline"); + + let mut with_future_duplicate = vec![ + CopyIdentityDocument::new( + "copy-b", + CopyKind::TemplateCopy, + AvailableTime::parse_rfc3339("2026-08-02T00:00:00Z").expect("available"), + ) + .expect("document"), + ]; + 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] +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) + ); + 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", + "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/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 { 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); +} 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..0464d83e4 --- /dev/null +++ b/crates/analysis_engine/tests/episode_membership_cutoff_duplicate_contract.rs @@ -0,0 +1,88 @@ +//! 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); +} 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..c219a095f --- /dev/null +++ b/crates/analysis_engine/tests/episode_membership_execution_contract.rs @@ -0,0 +1,312 @@ +//! 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, + EpisodeMembershipArtifact, EpisodeMembershipAssignment, MAX_EVIDENCE_UNITS, + execute_episode_membership_run, +}; +use episode_membership::EventWindow; +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 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 + .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()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(EPISODE_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[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:00Z"); +} + +#[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(); + 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) + ); +} + +#[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_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); +} 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..b8905f44e --- /dev/null +++ b/crates/analysis_engine/tests/inferred_status_execution_contract.rs @@ -0,0 +1,262 @@ +//! 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, 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 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 equivalent_cutoff_offsets_are_compared_by_instant() { + let mut equivalent = request(); + equivalent.knowledge_cutoff = "2026-08-01T09:00:00+09:00".into(); + let execution = execute(&equivalent, &mixed_evidence()).expect("equivalent cutoff instant"); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00Z"); +} + +#[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) + ); +} + +#[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/location_membership_artifact_bound_contract.rs b/crates/analysis_engine/tests/location_membership_artifact_bound_contract.rs new file mode 100644 index 000000000..9869b4284 --- /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, LOCATION_MEMBERSHIP_ARTIFACT_SCHEMA_VERSION, LocationMembershipArtifact, + MAX_EVIDENCE_UNITS, +}; + +#[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) + ); +} 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); +} 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"); +} 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..879385db4 --- /dev/null +++ b/crates/analysis_engine/tests/location_membership_execution_contract.rs @@ -0,0 +1,284 @@ +//! 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::{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, + 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![ + document("loc-a", LocationKind::Location), + document("ent-b", LocationKind::EntityIdentity), + document("lang-c", LocationKind::LanguageChannel), + ] +} + +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) + ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .statistic_count, + 5 + ); +} + +#[test] +fn empty_single_kind_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let location_only = vec![ + document("loc-a", LocationKind::Location), + document("loc-b", LocationKind::Location), + ]; + assert_eq!( + execute(&request, &location_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let entity_only = vec![ + document("ent-a", LocationKind::EntityIdentity), + document("ent-b", LocationKind::EntityIdentity), + ]; + assert_eq!( + execute(&request, &entity_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let language_only = vec![ + document("lang-a", LocationKind::LanguageChannel), + document("lang-b", LocationKind::LanguageChannel), + ]; + assert_eq!( + execute(&request, &language_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let no_location = vec![ + document("ent-a", LocationKind::EntityIdentity), + document("lang-b", LocationKind::LanguageChannel), + ]; + assert_eq!( + execute(&request, &no_location), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + document("same", LocationKind::Location), + document("same", LocationKind::EntityIdentity), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + 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] +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) + ); + 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", + "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) + ); + } + assert!( + execute_location_membership_run( + &request, + &accepted(&request), + "snapshot-location-membership", + cutoff(), + &documents, + "not-a-time", + ) + .is_err() + ); +} 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..882aff921 --- /dev/null +++ b/crates/analysis_engine/tests/membership_target_artifact_bound_contract.rs @@ -0,0 +1,32 @@ +//! 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); +} 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); +} 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..1eecf6cb6 --- /dev/null +++ b/crates/analysis_engine/tests/membership_target_execution_contract.rs @@ -0,0 +1,322 @@ +//! End-to-end contract for cutoff-safe membership-target refusals. + +use analysis_engine::{ + 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::{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, + 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![ + 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), + ] +} + +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 + .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()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(MEMBERSHIP_TARGET_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[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:00Z"); +} + +#[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(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let language_only = vec![ + document("lang-a", MembershipTargetKind::Language), + document("lang-b", MembershipTargetKind::Language), + ]; + assert_eq!( + execute(&request, &language_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let entity_only = vec![ + document("ent-a", MembershipTargetKind::Entity), + document("ent-b", MembershipTargetKind::Entity), + ]; + assert_eq!( + execute(&request, &entity_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let project_only = vec![ + document("proj-a", MembershipTargetKind::Project), + document("proj-b", MembershipTargetKind::Project), + ]; + assert_eq!( + execute(&request, &project_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let typed_only = vec![ + document("lang-a", MembershipTargetKind::Language), + document("ep-b", MembershipTargetKind::Episode), + ]; + assert_eq!( + execute(&request, &typed_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let persistence_only = vec![ + document("ent-a", MembershipTargetKind::Entity), + document("proj-b", MembershipTargetKind::Project), + ]; + assert_eq!( + execute(&request, &persistence_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + document("same", MembershipTargetKind::Language), + document("same", MembershipTargetKind::Entity), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + MembershipTargetDocument::new( + "", + MembershipTargetKind::Language, + available("2026-07-01T00:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { + 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) + ); + } + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + let kind = if index == MAX_EVIDENCE_UNITS { + MembershipTargetKind::Entity + } else { + MembershipTargetKind::Language + }; + document(format!("document-{index}"), kind) + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + 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_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); +} 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..615ef4f25 --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_cutoff_duplicate_contract.rs @@ -0,0 +1,88 @@ +//! 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); +} 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..9cc786f15 --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_execution_contract.rs @@ -0,0 +1,296 @@ +//! 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, 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 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) + ); +} + +#[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)) + ); +} 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..9ef846830 --- /dev/null +++ b/crates/analysis_engine/tests/subevent_containment_regression_contract.rs @@ -0,0 +1,97 @@ +//! 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" + ); +} 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..8809d522d --- /dev/null +++ b/crates/psychometric_core/tests/standardised_manifest_variance_error_messages.rs @@ -0,0 +1,25 @@ +//! Contract tests for standardised manifest-variance boundary error messages. + +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" + ); +} 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..53ddb6149 --- /dev/null +++ b/docs/adr/0058-copy-identity-analysis-run.md @@ -0,0 +1,87 @@ +# ADR 0058 — Template-copy identity refusals as an analysis-run output profile + +**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). +**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 and retained availability times; +- requires the request snapshot and knowledge cutoff to match the offered + input construction; +- 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 + 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 — 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 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/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 +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/0066-location-membership-analysis-run.md b/docs/adr/0066-location-membership-analysis-run.md new file mode 100644 index 000000000..7e35ae4f6 --- /dev/null +++ b/docs/adr/0066-location-membership-analysis-run.md @@ -0,0 +1,93 @@ +# 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 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 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. + +## 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, 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, +availability immediately at/after cutoff, oversized input, 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/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/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/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/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. 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. 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. diff --git a/docs/doctoring/location-membership-analysis-run.md b/docs/doctoring/location-membership-analysis-run.md new file mode 100644 index 000000000..099d2a66b --- /dev/null +++ b/docs/doctoring/location-membership-analysis-run.md @@ -0,0 +1,18 @@ +# 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 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`. +`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. 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. 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. 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 5a5a2201b341bdc8c62cc23f0a2f62fc4dbe9c36 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 11:20:29 +0900 Subject: [PATCH 32/47] fix(longitudinal): synchronize CWC lock dependency --- Cargo.lock | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84367638e..b9488611f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "320119579fcad9c21894d51470625266f50fe6898340abefa" [[package]] name = "aead" @@ -79,6 +79,7 @@ dependencies = [ "location_membership", "membership_core", "membership_target", + "psychometric_core", "relation_graph", "serde", "serde_json", @@ -1494,7 +1495,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "94143f3772519f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semantic_core" @@ -1957,7 +1958,7 @@ dependencies = [ name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "7490cfa5ec963746568740651ac6781f7019c6ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2020,7 +2021,7 @@ dependencies = [ name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f9803a1a85cd8dd28a47c1" [[package]] name = "url" @@ -2070,7 +2071,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de04398b2f370cd654f4ea44b" [[package]] name = "wasite" @@ -2427,4 +2428,4 @@ dependencies = [ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" \ No newline at end of file From 456f0a30f4dcaae10bc01bf902e83f75154d3dfb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 11:30:36 +0900 Subject: [PATCH 33/47] fix(longitudinal): restore Cargo registry checksum integrity --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9488611f..27e8a4c37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21894d51470625266f50fe6898340abefa" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" @@ -1495,7 +1495,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f3772519f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semantic_core" @@ -1958,7 +1958,7 @@ dependencies = [ name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f7019c6ea257c58e057f3ba8cf69e8da" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2021,7 +2021,7 @@ dependencies = [ name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f9803a1a85cd8dd28a47c1" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" @@ -2071,7 +2071,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de04398b2f370cd654f4ea44b" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasite" @@ -2428,4 +2428,4 @@ dependencies = [ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" \ No newline at end of file +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From 91377deed6fec2907ba34a33dd8bc122f58f3aa5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:10:40 +0900 Subject: [PATCH 34/47] test(cwc): reject all-singleton success artifact --- .../longitudinal_cwc_review_regressions.rs | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 39a304eba..b9e41ba66 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -31,6 +31,15 @@ fn request(cutoff: &str) -> AnalysisRunRequest { } } +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-longitudinal-cwc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + fn row( snapshot_id: &str, cluster_key: u64, @@ -63,15 +72,6 @@ fn rows() -> Vec { ] } -fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { - AnalysisRunAccepted::new( - "run-longitudinal-cwc", - "accepted", - &request.idempotency_key, - ) - .expect("accepted") -} - fn execute( request: &AnalysisRunRequest, scores: &[LongitudinalClusterScore], @@ -197,3 +197,15 @@ fn artifact_refuses_visible_count_impossible_for_the_executor() { Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) ); } + +#[test] +fn artifact_refuses_all_singleton_cluster_success_shape() { + let mut impossible = artifact(); + impossible.row_count = 2; + impossible.cluster_count = 2; + + assert_eq!( + impossible.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); +} From 5e61f7df1d8fc16c93ce75af05e92e2046988c94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:11:31 +0900 Subject: [PATCH 35/47] fix(cwc): reject all-singleton success artifact --- crates/analysis_engine/src/longitudinal_cwc_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index dadd76eb6..f7ed7be89 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -185,7 +185,7 @@ impl LongitudinalCwcArtifact { || self.row_count < 2 || self.row_count > max_rows || self.cluster_count < 2 - || self.cluster_count > self.row_count + || self.cluster_count >= self.row_count || !self.within_slope.is_finite() || !self.between_slope.is_finite() || !self.contextual_effect.is_finite() From c40d306327309dee20d988b5d6699e0c3e706925 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:11:52 +0900 Subject: [PATCH 36/47] test(cwc): keep singleton RED minimal --- .../longitudinal_cwc_review_regressions.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index b9e41ba66..af5500700 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -31,15 +31,6 @@ fn request(cutoff: &str) -> AnalysisRunRequest { } } -fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { - AnalysisRunAccepted::new( - "run-longitudinal-cwc", - "accepted", - &request.idempotency_key, - ) - .expect("accepted") -} - fn row( snapshot_id: &str, cluster_key: u64, @@ -72,6 +63,15 @@ fn rows() -> Vec { ] } +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-longitudinal-cwc", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + fn execute( request: &AnalysisRunRequest, scores: &[LongitudinalClusterScore], From da1a846901f54f8500a30ae4a4988289da170916 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:58:38 +0900 Subject: [PATCH 37/47] test(longitudinal): expose unbound admitted evidence payload --- .../longitudinal_cwc_review_regressions.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index af5500700..0657f849d 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -177,6 +177,48 @@ fn future_unavailable_cross_snapshot_rows_do_not_change_historical_replay() { assert_eq!(replay.terminal_result, baseline.terminal_result); } +#[test] +fn artifact_digest_commits_to_cutoff_visible_evidence_identity() { + let request = request("2026-08-01T00:00:00Z"); + let baseline_rows = rows(); + let baseline = execute(&request, &baseline_rows); + let mut substituted_rows = rows(); + substituted_rows[0] = LongitudinalClusterScore::new( + "evidence-substituted-visible-row", + SNAPSHOT_ID, + 1, + 0.0, + 2.0, + available("2026-07-01T00:00:00Z"), + ) + .expect("substituted row"); + let substituted = execute(&request, &substituted_rows); + + assert_eq!(substituted.artifact.row_count, baseline.artifact.row_count); + assert_eq!( + substituted.artifact.cluster_count, + baseline.artifact.cluster_count + ); + assert_eq!(substituted.artifact.within_slope, baseline.artifact.within_slope); + assert_eq!( + substituted.artifact.between_slope, + baseline.artifact.between_slope + ); + assert_eq!( + substituted.artifact.contextual_effect, + baseline.artifact.contextual_effect + ); + assert_ne!( + substituted.artifact.sha256().expect("substituted digest"), + baseline.artifact.sha256().expect("baseline digest"), + "digest-bound CWC artifact did not commit to the admitted evidence identity" + ); + assert_ne!( + substituted.terminal_result, baseline.terminal_result, + "terminal result did not distinguish a different admitted evidence population" + ); +} + #[test] fn artifact_refuses_inconsistent_contextual_effect() { let mut tampered = artifact(); From 625a3a4562dbdb3527e3377eabb9231fc374f1b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:59:36 +0900 Subject: [PATCH 38/47] fix(longitudinal): bind CWC artifact to admitted evidence payload --- .../src/longitudinal_cwc_artifact.rs | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index f7ed7be89..0d776e003 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -26,6 +26,8 @@ pub const LONGITUDINAL_CWC_OUTPUT_PROFILE: &str = "longitudinal_cwc_v1"; /// Maximum canonical artifact JSON size. pub const LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const LONGITUDINAL_CWC_INFERENCE_STATUS: &str = "composed_cwc_slopes_not_causal"; +const LONGITUDINAL_CWC_ADMITTED_EVIDENCE_DIGEST_DOMAIN: &[u8] = + b"tepp.longitudinal_cwc.admitted_evidence.v1\0"; /// One already-mapped clustered score offered to a cutoff-safe CWC run. #[derive(Clone, Debug, PartialEq)] @@ -121,6 +123,8 @@ pub struct LongitudinalCwcArtifact { pub snapshot_id: String, /// Historical evidence cutoff used by the composition. pub knowledge_cutoff: String, + /// Canonical SHA-256 commitment to the exact cutoff-visible evidence population. + pub admitted_evidence_sha256: String, /// Cutoff-visible clustered rows used by the scientific composition. pub row_count: u64, /// Distinct clusters among cutoff-visible rows. @@ -141,7 +145,7 @@ impl LongitudinalCwcArtifact { /// # Errors /// /// Returns [`AnalysisEngineError::InvalidLongitudinalCwcArtifact`] when the - /// schema, identifiers, counts, slopes, or claim boundary fail. + /// schema, identifiers, digest, counts, slopes, or claim boundary fail. pub fn from_json(payload: &str) -> Result { if payload.len() > LONGITUDINAL_CWC_ARTIFACT_BYTE_LIMIT { return Err(AnalysisEngineError::LimitExceeded); @@ -182,6 +186,7 @@ impl LongitudinalCwcArtifact { || !valid_identifier(&self.run_id) || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || !valid_sha256(&self.admitted_evidence_sha256) || self.row_count < 2 || self.row_count > max_rows || self.cluster_count < 2 @@ -212,12 +217,13 @@ fn admit_scores_at_cutoff( scores: &[LongitudinalClusterScore], snapshot_id: &str, knowledge_cutoff: KnowledgeCutoff, -) -> Result, AnalysisEngineError> { +) -> Result<(Vec, String), AnalysisEngineError> { if scores.len() > MAX_EVIDENCE_UNITS { return Err(AnalysisEngineError::LimitExceeded); } let mut evidence_ids = BTreeSet::new(); let mut eligible = Vec::new(); + let mut admitted = Vec::new(); for score in scores { if score.available_time.instant() > knowledge_cutoff.instant() { continue; @@ -228,6 +234,7 @@ fn admit_scores_at_cutoff( if !evidence_ids.insert(score.evidence_id.as_str()) { return Err(AnalysisEngineError::DuplicateEvidence); } + admitted.push(score); eligible.push(ClusteredScore { cluster_key: score.cluster_key, predictor: score.predictor, @@ -239,7 +246,46 @@ fn admit_scores_at_cutoff( PsychometricError::InvalidNumericInput, )); } - Ok(eligible) + let admitted_evidence_sha256 = digest_admitted_evidence(&mut admitted)?; + Ok((eligible, admitted_evidence_sha256)) +} + +fn digest_admitted_evidence( + admitted: &mut Vec<&LongitudinalClusterScore>, +) -> Result { + admitted.sort_unstable_by(|left, right| left.evidence_id.cmp(&right.evidence_id)); + let mut hasher = Sha256::new(); + hasher.update(LONGITUDINAL_CWC_ADMITTED_EVIDENCE_DIGEST_DOMAIN); + let row_count = + u64::try_from(admitted.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + hasher.update(row_count.to_be_bytes()); + for score in admitted { + update_length_prefixed(&mut hasher, score.evidence_id.as_bytes())?; + update_length_prefixed(&mut hasher, score.snapshot_id.as_bytes())?; + hasher.update(score.cluster_key.to_be_bytes()); + hasher.update(score.predictor.to_bits().to_be_bytes()); + hasher.update(score.outcome.to_bits().to_be_bytes()); + let available_time = score.available_time.to_rfc3339(); + update_length_prefixed(&mut hasher, available_time.as_bytes())?; + } + Ok(format_digest(hasher.finalize())) +} + +fn update_length_prefixed( + hasher: &mut Sha256, + value: &[u8], +) -> Result<(), AnalysisEngineError> { + let length = u32::try_from(value.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + hasher.update(length.to_be_bytes()); + hasher.update(value); + Ok(()) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } fn require_causal_refusal( @@ -261,6 +307,8 @@ fn require_causal_refusal( /// evidence identity, source snapshot, and availability provenance. Future-unavailable rows are /// removed before the historical identity/domain census and do not enter digest-bound output; /// duplicate identities among cutoff-visible evidence fail closed before scientific composition. +/// The artifact commits to the exact admitted evidence population with a versioned canonical +/// SHA-256 whose ordering is evidence-identity based and independent of source enumeration. /// This executor does not invent an ESEM/DSEM estimator, persist rows, or treat the recovered /// slopes as a causal effect. /// @@ -291,7 +339,8 @@ pub fn execute_longitudinal_cwc_run( return Err(AnalysisEngineError::InvalidEvidence); } - let eligible = admit_scores_at_cutoff(scores, snapshot_id, knowledge_cutoff)?; + let (eligible, admitted_evidence_sha256) = + admit_scores_at_cutoff(scores, snapshot_id, knowledge_cutoff)?; let slopes = recover_cluster_mean_within_between_slopes(&eligible)?; require_causal_refusal(claim_causal_effect(CausalHeuristic::TemporalPrecedence))?; @@ -308,6 +357,7 @@ pub fn execute_longitudinal_cwc_run( run_id: accepted.run_id.clone(), snapshot_id: snapshot_id.to_owned(), knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + admitted_evidence_sha256, row_count, cluster_count, within_slope: slopes.within_slope, @@ -348,6 +398,8 @@ mod tests { run_id: "run-1".into(), snapshot_id: "snapshot-1".into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + admitted_evidence_sha256: + "0000000000000000000000000000000000000000000000000000000000000000".into(), row_count: 4, cluster_count: 2, within_slope: 0.5, @@ -409,6 +461,16 @@ mod tests { value.knowledge_cutoff = "invalid".into(); value }, + { + let mut value = artifact.clone(); + value.admitted_evidence_sha256 = "0".repeat(63); + value + }, + { + let mut value = artifact.clone(); + value.admitted_evidence_sha256 = "A".repeat(64); + value + }, { let mut value = artifact.clone(); value.row_count = 1; From 676173fac5568f6147d560233aca5c1b9203da8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 12:59:57 +0900 Subject: [PATCH 39/47] test(longitudinal): migrate CWC artifact payload commitment fixture --- .../tests/longitudinal_cwc_review_regressions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 0657f849d..79f0bd867 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -93,6 +93,8 @@ fn artifact() -> LongitudinalCwcArtifact { run_id: "run-longitudinal-cwc".into(), snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + admitted_evidence_sha256: + "0000000000000000000000000000000000000000000000000000000000000000".into(), row_count: 4, cluster_count: 2, within_slope: 0.5, From d2159307ec8e289fb5c5850c09c05bd668c6429e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 13:01:48 +0900 Subject: [PATCH 40/47] test(longitudinal): prove admitted evidence commitment ordering --- .../tests/longitudinal_cwc_review_regressions.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs index 79f0bd867..3097f7021 100644 --- a/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs +++ b/crates/analysis_engine/tests/longitudinal_cwc_review_regressions.rs @@ -221,6 +221,19 @@ fn artifact_digest_commits_to_cutoff_visible_evidence_identity() { ); } +#[test] +fn admitted_payload_commitment_is_stable_under_benign_row_permutation() { + let request = request("2026-08-01T00:00:00Z"); + let baseline_rows = rows(); + let baseline = execute(&request, &baseline_rows); + let mut permuted_rows = rows(); + permuted_rows.reverse(); + let permuted = execute(&request, &permuted_rows); + + assert_eq!(permuted.artifact, baseline.artifact); + assert_eq!(permuted.terminal_result, baseline.terminal_result); +} + #[test] fn artifact_refuses_inconsistent_contextual_effect() { let mut tampered = artifact(); From 988679869a8241ec5a7f932ee9fd1bf3df77dd42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 13:02:23 +0900 Subject: [PATCH 41/47] refactor(longitudinal): keep admitted digest helper slice-bounded --- crates/analysis_engine/src/longitudinal_cwc_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 0d776e003..06dfa37e2 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -251,7 +251,7 @@ fn admit_scores_at_cutoff( } fn digest_admitted_evidence( - admitted: &mut Vec<&LongitudinalClusterScore>, + admitted: &mut [&LongitudinalClusterScore], ) -> Result { admitted.sort_unstable_by(|left, right| left.evidence_id.cmp(&right.evidence_id)); let mut hasher = Sha256::new(); From 58f021e547da659ab800974bedb83d5e0fb3e5a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 13:02:43 +0900 Subject: [PATCH 42/47] docs(adr): bind CWC output to admitted evidence provenance --- .../adr/0033-longitudinal-cwc-analysis-run.md | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/adr/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md index c41187a2e..d98472001 100644 --- a/docs/adr/0033-longitudinal-cwc-analysis-run.md +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -11,7 +11,7 @@ Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered within/between OLS and the CWC contextual effect inside `psychometric_core`. Operators still cannot request that composition as a digest-bound analysis-run output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169), and branch-local implementation does not make this ADR protected-main authority. -Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result; and snapshot provenance was checked before availability admission, so a future-only row from another snapshot could turn an earlier successful replay into `SnapshotMismatch`. +Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result; snapshot provenance was checked before availability admission, so a future-only row from another snapshot could turn an earlier successful replay into `SnapshotMismatch`; an all-singleton count shape could pass artifact validation even though the CWC executor cannot produce a successful within slope for it; and the completed artifact dropped the admitted evidence identity/numeric provenance after validation, so two different cutoff-visible populations could produce the same artifact digest whenever their counts and slopes coincided. ## Decision @@ -25,15 +25,19 @@ The executor: - rejects a cross-snapshot row as a provenance violation when that row is cutoff-visible; - rejects duplicate `evidence_id` among cutoff-visible evidence so one observation cannot acquire extra scientific weight; - does not infer identity from cluster/predictor/outcome/time tuples, so numerically equal observations with distinct identities remain distinct evidence; -- keeps future-unavailable evidence out of public historical counts, artifact digest, and terminal result; it does not emit an excluded-future counter; +- keeps future-unavailable evidence out of public historical counts, admitted-evidence commitment, artifact digest, and terminal result; it does not emit an excluded-future counter; +- commits the exact cutoff-visible evidence population to `admitted_evidence_sha256` before scientific composition using a versioned, domain-separated, length-delimited binary encoding of `evidence_id`, `snapshot_id`, `cluster_key`, exact predictor/outcome binary64 bits, and canonical availability time; +- canonicalizes only that provenance commitment by immutable `evidence_id`, so source enumeration does not alter the commitment; estimator inputs are not sorted or numerically rewritten as a workaround for #596; +- rejects malformed or non-lowercase SHA-256 values when a standalone artifact is imported; - binds request and executor cutoffs by parsed `KnowledgeCutoff::instant()` equality rather than RFC 3339 text; - invokes `recover_cluster_mean_within_between_slopes` without reimplementing CWC arithmetic; - requires the exact `claim_causal_effect(CausalHeuristic::TemporalPrecedence)` refusal and fails closed if that provider contract drifts; +- requires at least one non-singleton cluster in a successful artifact (`cluster_count < row_count`) while not claiming that this count rule alone proves nonsingularity; - validates the contextual-effect identity exactly against the recovered within/between slopes; - emits terminal provider status `validated` separately from artifact inference status `composed_cwc_slopes_not_causal`; - emits canonical SHA-256-bound `tepp.longitudinal_cwc.v1` output and does not persist raw rows. -This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, or causal identification. +The admitted-evidence digest is a content/provenance commitment, not source-generator attestation, construct-validity evidence, or scientific acceptance. This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, or causal identification. ## Alternatives considered @@ -45,16 +49,19 @@ This is two-level OLS composition, not DSEM, RI-CLPM, a random-effects sampler, 6. Count rows excluded after the historical cutoff in the public artifact — rejected because later corpus existence would change an earlier replay and its digest. 7. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. 8. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. +9. Treat counts and recovered slopes as sufficient artifact provenance — rejected because different admitted evidence identities or coordinates can yield the same summary and therefore the same result digest (#600). +10. Hash rows in caller/source order — rejected because enumeration order is not evidence identity and would introduce a second order-dependence while #596 is already tracking the separate numerical-order defect. +11. Sort estimator inputs to make #596 pass — rejected because that would hide rather than repair reusable finite-binary64 arithmetic ownership; only the provenance digest is canonicalized by identity. ## Scientific acceptance boundary -The identity and leakage repairs establish input/output integrity, not commercial scientific acceptance. Issue #501 owns repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; unequal follow-up/time-varying availability; and leakage-safe rolling-origin evaluation. +The identity, leakage, structural-wire, and provenance-commitment repairs establish input/output integrity, not commercial scientific acceptance. Issue #501 owns repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; unequal follow-up/time-varying availability; and leakage-safe rolling-origin evaluation. Issue #596 separately owns the unresolved row-permutation numerical invariant and may turn GREEN only after TEPP consumes an immutable released reusable finite-binary64 mean contract from fast-mlsirm. -The profile must not be described as scientifically accepted or release-ready while #501 remains open without equivalent checked-in evidence. +The profile must not be described as scientifically accepted or release-ready while #501 remains open without equivalent checked-in evidence or while #596 remains unresolved. ## Consequences -A historical CWC run is invariant to evidence that was unavailable at its cutoff, including a future-only row carrying another snapshot identity. Snapshot provenance still fails closed for rows in the evidence population that was actually observable then. Duplicate identities in that cutoff-visible population fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes; future-only census information is not part of the digest-bound historical result. +A historical CWC run is invariant to evidence that was unavailable at its cutoff, including a future-only row carrying another snapshot identity. Snapshot provenance still fails closed for rows in the evidence population that was actually observable then. Duplicate identities in that cutoff-visible population fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes plus a source-text-free SHA-256 commitment to the exact admitted population; future-only census information is not part of the digest-bound historical result. Changing a cutoff-visible evidence identity or numeric/provenance field changes that commitment even when the recovered summary statistics happen to remain equal. Shared ADR index, TRACEABILITY and product-gap currentization belong to the canonical documentation/consolidation lane. This branch-local ADR remains `Proposed` until the implementation is inherited by protected-main authority and its merge/release gates are satisfied. @@ -67,8 +74,8 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` -Regression contracts cover equivalent cutoff instants, cutoff-before-snapshot admission, visible cross-snapshot refusal, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, contextual-effect tampering, impossible visible counts, provider/domain status separation, and causal-refusal fail-closed behavior. Scientific acceptance remains #501. +Regression contracts cover equivalent cutoff instants, cutoff-before-snapshot admission, visible cross-snapshot refusal, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, admitted-evidence digest sensitivity, benign permutation stability of the commitment, contextual-effect tampering, impossible visible counts and all-singleton success shapes, provider/domain status separation, and causal-refusal fail-closed behavior. The pathological finite-binary64 permutation contract remains intentionally RED under #596 until the released numerical owner is available. Scientific acceptance remains #501. ## Rollback and supersession -Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable evidence/snapshot/availability provenance, and retains cutoff-visible historical replay invariance. +Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable evidence/snapshot/availability provenance, retains cutoff-visible historical replay invariance, and keeps the digest-bound result tied to the exact admitted evidence population without making source enumeration part of the estimand. From 7a7e82f6af661ce07a356538f4507fe1f63ef0e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 13:03:05 +0900 Subject: [PATCH 43/47] docs(doctoring): record CWC admitted evidence commitment --- .../longitudinal-cwc-analysis-run.md | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index cfdf8592e..94680711b 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -6,35 +6,43 @@ **Evidence-identity integrity owner:** #592 **Historical-artifact leakage owner:** #593 **Cutoff-before-snapshot provenance owner:** #595 +**Finite-binary64 permutation owner:** #596 / released fast-mlsirm numerical contract +**Lockfile integrity owner:** #597 +**Artifact structural-validity owner:** #599 +**Admitted-evidence commitment owner:** #600 Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, emitted `excluded_after_cutoff_count` so later-only corpus existence altered an earlier artifact digest, and checked row snapshot provenance before availability admission so a future-only row from another snapshot could fail an otherwise identical historical replay. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, emitted `excluded_after_cutoff_count` so later-only corpus existence altered an earlier artifact digest, checked row snapshot provenance before availability admission so a future-only row from another snapshot could fail an otherwise identical historical replay, accepted an all-singleton success-artifact count shape that the executor cannot produce, and after admission dropped the exact evidence identity/numeric provenance from the digest-bound artifact. Current branch behavior is stricter: - every `LongitudinalClusterScore` carries an opaque immutable `evidence_id`, source `snapshot_id`, and `AvailableTime`; - `evidence_id` and `snapshot_id` use the existing bounded analysis identifier contract; - raw input cardinality is bounded before filtering; -- rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before snapshot, identity, or scientific-domain admission; +- rows with `AvailableTime > knowledge_cutoff` are excluded from the historical evidence population before snapshot, identity, scientific-domain, or admitted-payload commitment; - cross-snapshot input fails closed when the row is cutoff-visible; - repeated identity among cutoff-visible evidence fails closed with `AnalysisEngineError::DuplicateEvidence` before CWC composition; - future-unavailable rows, including rows reusing a visible identity or carrying another snapshot identity, do not change the historical artifact or terminal result; - public artifact counts are cutoff-visible `row_count` and `cluster_count`; no future-only exclusion counter is emitted; - numerically equal rows with distinct evidence identities remain distinct evidence; predictor/outcome/cluster/time tuples are never treated as identity; +- `admitted_evidence_sha256` commits to the exact cutoff-visible population using a versioned domain tag, explicit count and length delimiters, opaque evidence/snapshot IDs, cluster key, exact predictor/outcome binary64 bits, and canonical availability time; +- only the provenance commitment is canonicalized by immutable evidence ID, so benign source enumeration does not change it; estimator inputs remain untouched and #596 is not hidden by sorting; +- standalone artifact import rejects malformed or non-lowercase admitted-evidence digests; +- a successful artifact requires `cluster_count < row_count`, rejecting the impossible all-singleton count shape without claiming this structural rule proves full-rank design; - equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; - `contextual_effect` must equal the exact `between_slope - within_slope` value produced by the owner contract; - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. -The snapshot and identity rules are both historical-population rules, not raw-corpus side channels. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. A cutoff-visible row from another snapshot similarly violates provenance. Conversely, validating or counting later-unavailable rows would leak future corpus state into an earlier replay. +The snapshot, identity, and admitted-payload rules are historical-population rules, not raw-corpus side channels. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. A cutoff-visible row from another snapshot similarly violates provenance. Conversely, validating, counting, or hashing later-unavailable rows would leak future corpus state into an earlier replay. The new commitment is source-text-free content identity; it does not attest which upstream mapping implementation produced those coordinates. RED / repair lineage on this branch: - `6fd006e6d582c0a79cca7c007f7db4e8540409d1` → `ec2bc21c71d2601e5b81073459fd6347080b97df`: cutoff identity, artifact consistency, count bounds, provider/domain separation, causal-refusal enforcement; -- `99dbf5ea2a3876575f3f52557e36d903d6a05cf4` → `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` → `90eb364334175e1b0f3924eaf278f2bc5c26efa1`: snapshot/availability provenance and fixture migration; +- `99dbf5ea2a3876575f3f52557e36d9036a05cf4` → `5efa234b7fe9fd5cfef0998ee473b7ccc9887b3f` → `90eb364334175e1b0f3924eaf278f2bc5c26efa1`: snapshot/availability provenance and fixture migration; - `e66a90f3c6be7c04ecc9a310baf955b16dbd6f76` introduces #592 evidence identity and visible-duplicate/equal-value contracts but initially over-constrains a future-unavailable duplicate; - `138be1fb8ad3ae47a18d7e05645d2ca2830cd341` adds production evidence identity and the first duplicate check; - `66cffe0c88f2c8e54881f6018a776c62daaf788c` / `418222f232dfc1eb8d6fa27d841e2302148ccf55` migrate fixtures; @@ -46,14 +54,20 @@ RED / repair lineage on this branch: - `98daaa73422f65fd8be9dfdb9e0cd0df50192617` currentizes ADR 0033 on evidence identity and historical replay; - `0ea8f6573d8519762e3ef9038ec4c4892c8e3a8b` is the #595 public RED: cutoff-visible foreign-snapshot evidence still fails closed, while a future-unavailable foreign-snapshot row must leave artifact and terminal result unchanged; - `07360220cf8e7018107b271dc8e6e2c2f49b0c58` is the #595 causal repair: availability admission now precedes snapshot provenance, without weakening visible snapshot refusal or the raw cardinality ceiling; +- `2a05f4d38f5136d32ab0170249dd3c47cf95949e` is the #596 public consumer RED proving pathological finite-binary64 row order can alter the result under the current numerical owner; no local summation repair is authorized; +- `5a5a2201b341bdc8c62cc23f0a2f62fc4dbe9c36` is retained as the #597 lockfile-integrity RED, and `456f0a30f4dcaae10bc01bf902e83f75154d3dfb` restores inherited registry checksums while preserving only the intended `psychometric_core` dependency delta; +- `91377deed6fec2907ba34a33dd8bc122f58f3aa5` → `5e61f7df1d8fc16c93ce75af05e92e2046988c94` → `c40d306327309dee20d988b5d6699e0c3e706925`: #599 proves and minimally repairs the impossible all-singleton success-artifact shape; +- `da1a846901f54f8500a30ae4a4988289da170916` is the #600 public RED: changing one cutoff-visible opaque evidence identity while keeping coordinates, counts, and slopes equal must change the digest-bound result; +- `625a3a4562dbdb3527e3377eabb9231fc374f1b9` adds the versioned domain-separated admitted-evidence commitment and standalone digest validation; +- `676173fac5568f6147d560233aca5c1b9203da8d` migrates the public artifact fixture, `d2159307ec8e289fb5c5850c09c05bd668c6429e` proves benign permutation stability for the new commitment, and `988679869a8241ec5a7f932ee9fd1bf3df77dd42` keeps the digest helper slice-bounded without changing behavior; - ADR 0033 remains `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe rolling-origin evaluation. -#592, #593 and #595 are prerequisites for an uncontaminated historical recovery population, not substitutes for #501. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. +#592, #593, #595, #597, #599 and #600 are integrity prerequisites, not substitutes for #501. #596 is a separate unresolved numerical invariant whose repair belongs to the released fast-mlsirm finite-binary64 owner. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. ## Merge boundary -The PR remains Draft until #592/#593/#595 survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. +The PR remains Draft until the valid #592/#593/#595/#596/#597/#599/#600 deltas survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. From a5a039e4001e01d1d089e14c6c548d6d5ea60842 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 14:01:46 +0900 Subject: [PATCH 44/47] test(temporal): reject noncanonical CWC cutoff spellings --- ...gitudinal_cwc_cutoff_canonical_contract.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 crates/analysis_engine/tests/longitudinal_cwc_cutoff_canonical_contract.rs diff --git a/crates/analysis_engine/tests/longitudinal_cwc_cutoff_canonical_contract.rs b/crates/analysis_engine/tests/longitudinal_cwc_cutoff_canonical_contract.rs new file mode 100644 index 000000000..28f943676 --- /dev/null +++ b/crates/analysis_engine/tests/longitudinal_cwc_cutoff_canonical_contract.rs @@ -0,0 +1,32 @@ +use analysis_engine::{ + AnalysisEngineError, LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION, LongitudinalCwcArtifact, +}; + +fn artifact(knowledge_cutoff: &str) -> LongitudinalCwcArtifact { + LongitudinalCwcArtifact { + schema_version: LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-cutoff-canonical".into(), + snapshot_id: "snapshot-cutoff-canonical".into(), + knowledge_cutoff: knowledge_cutoff.into(), + admitted_evidence_sha256: + "0000000000000000000000000000000000000000000000000000000000000000".into(), + row_count: 4, + cluster_count: 2, + within_slope: 0.5, + between_slope: 2.0, + contextual_effect: 1.5, + inference_status: "composed_cwc_slopes_not_causal".into(), + } +} + +#[test] +fn semantically_equivalent_noncanonical_cutoff_text_fails_closed() { + let canonical = artifact("2026-08-01T00:00:00Z"); + assert!(canonical.to_json().is_ok()); + + let offset_spelling = artifact("2026-08-01T09:00:00+09:00"); + assert_eq!( + offset_spelling.to_json(), + Err(AnalysisEngineError::InvalidLongitudinalCwcArtifact) + ); +} From 6c06c0de11d565f09e4cdba48834165f4518e909 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 14:02:21 +0900 Subject: [PATCH 45/47] fix(temporal): canonicalize CWC cutoff identity --- crates/analysis_engine/src/longitudinal_cwc_artifact.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs index 06dfa37e2..a696847a8 100644 --- a/crates/analysis_engine/src/longitudinal_cwc_artifact.rs +++ b/crates/analysis_engine/src/longitudinal_cwc_artifact.rs @@ -181,11 +181,13 @@ impl LongitudinalCwcArtifact { fn validate(&self) -> Result<(), AnalysisEngineError> { let max_rows = u64::try_from(MAX_EVIDENCE_UNITS) .map_err(|_| AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; + let knowledge_cutoff = KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidLongitudinalCwcArtifact)?; let expected_contextual_effect = self.between_slope - self.within_slope; if self.schema_version != LONGITUDINAL_CWC_ARTIFACT_SCHEMA_VERSION || !valid_identifier(&self.run_id) || !valid_identifier(&self.snapshot_id) - || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || knowledge_cutoff.to_rfc3339() != self.knowledge_cutoff || !valid_sha256(&self.admitted_evidence_sha256) || self.row_count < 2 || self.row_count > max_rows From fe7a2821b2504841dd7696935198defd93148d4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 14:04:46 +0900 Subject: [PATCH 46/47] docs(adr): record canonical CWC cutoff identity --- docs/adr/0033-longitudinal-cwc-analysis-run.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/adr/0033-longitudinal-cwc-analysis-run.md b/docs/adr/0033-longitudinal-cwc-analysis-run.md index d98472001..0604c0430 100644 --- a/docs/adr/0033-longitudinal-cwc-analysis-run.md +++ b/docs/adr/0033-longitudinal-cwc-analysis-run.md @@ -11,13 +11,13 @@ Protected main already recovers Enders and Tofighi (2007) cluster-mean-centered within/between OLS and the CWC contextual effect inside `psychometric_core`. Operators still cannot request that composition as a digest-bound analysis-run output. Recovery primitives alone are not the ESEM/DSEM engine (GAP-006 / #169), and branch-local implementation does not make this ADR protected-main authority. -Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result; snapshot provenance was checked before availability admission, so a future-only row from another snapshot could turn an earlier successful replay into `SnapshotMismatch`; an all-singleton count shape could pass artifact validation even though the CWC executor cannot produce a successful within slope for it; and the completed artifact dropped the admitted evidence identity/numeric provenance after validation, so two different cutoff-visible populations could produce the same artifact digest whenever their counts and slopes coincided. +Review found several application-boundary defects: equivalent RFC 3339 spellings of one cutoff instant were rejected at the request/executor boundary; completed artifacts did not enforce `contextual_effect = between_slope - within_slope`; artifact evidence counts could exceed the executor population bound; the causal-refusal provider result was discarded; clustered rows lacked immutable source snapshot and evidence identities; a public `excluded_after_cutoff_count` caused later-only corpus existence to alter an earlier digest-bound result; snapshot provenance was checked before availability admission, so a future-only row from another snapshot could turn an earlier successful replay into `SnapshotMismatch`; an all-singleton count shape could pass artifact validation even though the CWC executor cannot produce a successful within slope for it; the completed artifact dropped the admitted evidence identity/numeric provenance after validation, so two different cutoff-visible populations could produce the same artifact digest whenever their counts and slopes coincided; and standalone artifact validation accepted multiple RFC 3339 spellings for the same cutoff instant, allowing multiple valid byte strings and SHA-256 identities for a contract described as canonical. ## Decision Add the `longitudinal_cwc_v1` analysis-run output profile to `analysis_engine`, while keeping the reusable numerical estimator in `psychometric_core`. -The executor: +The executor and artifact boundary: - requires every clustered score to carry bounded opaque `evidence_id`, immutable `snapshot_id`, and `AvailableTime` provenance; - preserves the raw `MAX_EVIDENCE_UNITS` operational admission ceiling; @@ -30,6 +30,7 @@ The executor: - canonicalizes only that provenance commitment by immutable `evidence_id`, so source enumeration does not alter the commitment; estimator inputs are not sorted or numerically rewritten as a workaround for #596; - rejects malformed or non-lowercase SHA-256 values when a standalone artifact is imported; - binds request and executor cutoffs by parsed `KnowledgeCutoff::instant()` equality rather than RFC 3339 text; +- emits and accepts only the canonical `KnowledgeCutoff::to_rfc3339()` spelling inside the digest-bound artifact, so one cutoff instant has one artifact text identity; - invokes `recover_cluster_mean_within_between_slopes` without reimplementing CWC arithmetic; - requires the exact `claim_causal_effect(CausalHeuristic::TemporalPrecedence)` refusal and fails closed if that provider contract drifts; - requires at least one non-singleton cluster in a successful artifact (`cluster_count < row_count`) while not claiming that this count rule alone proves nonsingularity; @@ -47,7 +48,7 @@ The admitted-evidence digest is a content/provenance commitment, not source-gene 4. Validate snapshot provenance before availability — rejected because a row that did not exist at the historical cutoff would then be able to alter that replay solely through future corpus state (#595). 5. Deduplicate by predictor/outcome/cluster/time tuple — rejected because equal observed values do not imply the same evidence unit. 6. Count rows excluded after the historical cutoff in the public artifact — rejected because later corpus existence would change an earlier replay and its digest. -7. Treat equivalent RFC 3339 text as different cutoffs — rejected because textual representation is not temporal identity. +7. Treat equivalent RFC 3339 text as different temporal cutoffs — rejected for request/executor binding because textual representation is not temporal identity. Accept multiple equivalent spellings in a canonical digest-bound artifact — also rejected because that creates more than one valid byte/digest identity for one cutoff instant (#601). 8. Reimplement CWC arithmetic in the adapter — rejected; `psychometric_core` remains the canonical numerical owner. 9. Treat counts and recovered slopes as sufficient artifact provenance — rejected because different admitted evidence identities or coordinates can yield the same summary and therefore the same result digest (#600). 10. Hash rows in caller/source order — rejected because enumeration order is not evidence identity and would introduce a second order-dependence while #596 is already tracking the separate numerical-order defect. @@ -55,13 +56,13 @@ The admitted-evidence digest is a content/provenance commitment, not source-gene ## Scientific acceptance boundary -The identity, leakage, structural-wire, and provenance-commitment repairs establish input/output integrity, not commercial scientific acceptance. Issue #501 owns repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; unequal follow-up/time-varying availability; and leakage-safe rolling-origin evaluation. Issue #596 separately owns the unresolved row-permutation numerical invariant and may turn GREEN only after TEPP consumes an immutable released reusable finite-binary64 mean contract from fast-mlsirm. +The identity, leakage, structural-wire, provenance-commitment, and canonical-cutoff repairs establish input/output integrity, not commercial scientific acceptance. Issue #501 owns repeated true-parameter recovery for within, between and contextual slopes; RMSE/bias with Monte Carlo uncertainty; attempted/recovered/failed denominators; cluster-size and signal/noise variation; unequal follow-up/time-varying availability; and leakage-safe rolling-origin evaluation. Issue #596 separately owns the unresolved row-permutation numerical invariant and may turn GREEN only after TEPP consumes an immutable released reusable finite-binary64 mean contract from fast-mlsirm. The profile must not be described as scientifically accepted or release-ready while #501 remains open without equivalent checked-in evidence or while #596 remains unresolved. ## Consequences -A historical CWC run is invariant to evidence that was unavailable at its cutoff, including a future-only row carrying another snapshot identity. Snapshot provenance still fails closed for rows in the evidence population that was actually observable then. Duplicate identities in that cutoff-visible population fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes plus a source-text-free SHA-256 commitment to the exact admitted population; future-only census information is not part of the digest-bound historical result. Changing a cutoff-visible evidence identity or numeric/provenance field changes that commitment even when the recovered summary statistics happen to remain equal. +A historical CWC run is invariant to evidence that was unavailable at its cutoff, including a future-only row carrying another snapshot identity. Snapshot provenance still fails closed for rows in the evidence population that was actually observable then. Duplicate identities in that cutoff-visible population fail closed before CWC arithmetic, while distinct evidence with equal values remains admissible. The artifact exposes only cutoff-visible scientific counts and slopes plus a source-text-free SHA-256 commitment to the exact admitted population; future-only census information is not part of the digest-bound historical result. Changing a cutoff-visible evidence identity or numeric/provenance field changes that commitment even when the recovered summary statistics happen to remain equal. Request matching treats equivalent legal RFC 3339 spellings as the same cutoff instant, while the persisted/digest-bound artifact uses one canonical UTC spelling for that instant. Shared ADR index, TRACEABILITY and product-gap currentization belong to the canonical documentation/consolidation lane. This branch-local ADR remains `Proposed` until the implementation is inherited by protected-main authority and its merge/release gates are satisfied. @@ -74,8 +75,8 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` -Regression contracts cover equivalent cutoff instants, cutoff-before-snapshot admission, visible cross-snapshot refusal, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, admitted-evidence digest sensitivity, benign permutation stability of the commitment, contextual-effect tampering, impossible visible counts and all-singleton success shapes, provider/domain status separation, and causal-refusal fail-closed behavior. The pathological finite-binary64 permutation contract remains intentionally RED under #596 until the released numerical owner is available. Scientific acceptance remains #501. +Regression contracts cover equivalent request cutoff instants, canonical artifact cutoff spelling, cutoff-before-snapshot admission, visible cross-snapshot refusal, snapshot/evidence provenance, cutoff-visible duplicate refusal, future-unavailable replay invariance, equal-value distinct identities, admitted-evidence digest sensitivity, benign permutation stability of the commitment, contextual-effect tampering, impossible visible counts and all-singleton success shapes, provider/domain status separation, and causal-refusal fail-closed behavior. The pathological finite-binary64 permutation contract remains intentionally RED under #596 until the released numerical owner is available. Scientific acceptance remains #501. ## Rollback and supersession -Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable evidence/snapshot/availability provenance, retains cutoff-visible historical replay invariance, and keeps the digest-bound result tied to the exact admitted evidence population without making source enumeration part of the estimand. +Rollback removes the `longitudinal_cwc_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps CWC distinct from between-cluster effects and causal identification, preserves immutable evidence/snapshot/availability provenance, retains cutoff-visible historical replay invariance, uses one canonical cutoff representation for digest-bound artifact identity, and keeps the result tied to the exact admitted evidence population without making source enumeration part of the estimand. From 47b6a6c85c69983bd843d8bb6d19001cfd3629d8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 19 Sep 2026 14:05:08 +0900 Subject: [PATCH 47/47] docs(doctoring): trace canonical CWC cutoff repair --- docs/doctoring/longitudinal-cwc-analysis-run.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/longitudinal-cwc-analysis-run.md b/docs/doctoring/longitudinal-cwc-analysis-run.md index 94680711b..2c3dff6d9 100644 --- a/docs/doctoring/longitudinal-cwc-analysis-run.md +++ b/docs/doctoring/longitudinal-cwc-analysis-run.md @@ -10,12 +10,13 @@ **Lockfile integrity owner:** #597 **Artifact structural-validity owner:** #599 **Admitted-evidence commitment owner:** #600 +**Canonical cutoff artifact-identity owner:** #601 Protected main owns Enders and Tofighi (2007) CWC within/between/contextual OLS in `psychometric_core`. This branch only composes that owner into `analysis_engine`; it does not implement a second estimator, DSEM, RI-CLPM, persistence, or causal identification. ## Repaired application contract -The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, emitted `excluded_after_cutoff_count` so later-only corpus existence altered an earlier artifact digest, checked row snapshot provenance before availability admission so a future-only row from another snapshot could fail an otherwise identical historical replay, accepted an all-singleton success-artifact count shape that the executor cannot produce, and after admission dropped the exact evidence identity/numeric provenance from the digest-bound artifact. +The predecessor profile was not fully historical or self-consistent. It compared request/executor cutoffs as RFC 3339 text, trusted a run-level snapshot label while individual rows lacked snapshot provenance, accepted artifact counts outside the executable population envelope, accepted a finite but inconsistent contextual effect, discarded the causal-refusal provider result, reused the scientific inference label as terminal provider validation state, dropped the opaque evidence identity needed to prevent cutoff-visible replay/pseudo-replication, emitted `excluded_after_cutoff_count` so later-only corpus existence altered an earlier artifact digest, checked row snapshot provenance before availability admission so a future-only row from another snapshot could fail an otherwise identical historical replay, accepted an all-singleton success-artifact count shape that the executor cannot produce, after admission dropped the exact evidence identity/numeric provenance from the digest-bound artifact, and accepted multiple RFC 3339 artifact spellings for the same cutoff instant even though artifact serialization/digesting is defined as canonical. Current branch behavior is stricter: @@ -32,12 +33,13 @@ Current branch behavior is stricter: - only the provenance commitment is canonicalized by immutable evidence ID, so benign source enumeration does not change it; estimator inputs remain untouched and #596 is not hidden by sorting; - standalone artifact import rejects malformed or non-lowercase admitted-evidence digests; - a successful artifact requires `cluster_count < row_count`, rejecting the impossible all-singleton count shape without claiming this structural rule proves full-rank design; -- equivalent legal RFC 3339 spellings bind by `KnowledgeCutoff::instant()`; +- equivalent legal RFC 3339 request/executor spellings bind by `KnowledgeCutoff::instant()`; +- the digest-bound artifact accepts only the exact `KnowledgeCutoff::to_rfc3339()` representation, preventing more than one valid wire/digest identity for the same cutoff instant; - `contextual_effect` must equal the exact `between_slope - within_slope` value produced by the owner contract; - the exact `CausalUnderidentified` refusal is required, while unexpected success or another provider error fails closed; - terminal `validation_status` is `validated`; the artifact separately carries `composed_cwc_slopes_not_causal`. -The snapshot, identity, and admitted-payload rules are historical-population rules, not raw-corpus side channels. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. A cutoff-visible row from another snapshot similarly violates provenance. Conversely, validating, counting, or hashing later-unavailable rows would leak future corpus state into an earlier replay. The new commitment is source-text-free content identity; it does not attest which upstream mapping implementation produced those coordinates. +The snapshot, identity, admitted-payload, and canonical-cutoff rules are historical-result integrity rules, not raw-corpus side channels. Replaying one cutoff-visible source row can change stacked within-cluster weighting, cluster means, within/between slopes, contextual effect, and `row_count`; allowing it would turn transport replay into a scientific weighting rule. A cutoff-visible row from another snapshot similarly violates provenance. Conversely, validating, counting, or hashing later-unavailable rows would leak future corpus state into an earlier replay. The new commitment is source-text-free content identity; it does not attest which upstream mapping implementation produced those coordinates. Canonical request matching and canonical artifact text serve different purposes: the former compares temporal instants, while the latter ensures one digest-bound representation per instant. RED / repair lineage on this branch: @@ -60,14 +62,16 @@ RED / repair lineage on this branch: - `da1a846901f54f8500a30ae4a4988289da170916` is the #600 public RED: changing one cutoff-visible opaque evidence identity while keeping coordinates, counts, and slopes equal must change the digest-bound result; - `625a3a4562dbdb3527e3377eabb9231fc374f1b9` adds the versioned domain-separated admitted-evidence commitment and standalone digest validation; - `676173fac5568f6147d560233aca5c1b9203da8d` migrates the public artifact fixture, `d2159307ec8e289fb5c5850c09c05bd668c6429e` proves benign permutation stability for the new commitment, and `988679869a8241ec5a7f932ee9fd1bf3df77dd42` keeps the digest helper slice-bounded without changing behavior; +- `a5a039e4001e01d1d089e14c6c548d6d5ea60842` is the #601 public RED: canonical UTC remains valid while a semantically equivalent noncanonical offset spelling must fail standalone artifact validation; +- `6c06c0de11d565f09e4cdba48834165f4518e909` is the #601 minimal causal repair: artifact validation parses through `KnowledgeCutoff` once and requires exact equality with `to_rfc3339()`; - ADR 0033 remains `Proposed`, not protected-main `Accepted` authority. ## Scientific evidence boundary Existing known-truth CWC tests are useful regression evidence, but one noiseless profile fixture does not establish commercial recovery. Issue #501 requires repeated true-parameter recovery with RMSE, bias, Monte Carlo uncertainty, explicit attempted/recovered/failed denominators, cluster-size and signal/noise variation, unequal follow-up/time-varying availability, and leakage-safe rolling-origin evaluation. -#592, #593, #595, #597, #599 and #600 are integrity prerequisites, not substitutes for #501. #596 is a separate unresolved numerical invariant whose repair belongs to the released fast-mlsirm finite-binary64 owner. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. +#592, #593, #595, #597, #599, #600 and #601 are integrity prerequisites, not substitutes for #501. #596 is a separate unresolved numerical invariant whose repair belongs to the released fast-mlsirm finite-binary64 owner. Do not promote this profile to scientific acceptance or release readiness from deterministic fixtures alone. LLM judgments are not numerical acceptance evidence. ## Merge boundary -The PR remains Draft until the valid #592/#593/#595/#596/#597/#599/#600 deltas survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change. +The PR remains Draft until the valid #592/#593/#595/#596/#597/#599/#600/#601 deltas survive the #416 fold, exact-head Rust/documentation/security/coverage gates, review-thread resolution, qualifying current-head independent approval, shared documentation consolidation, and #501 or equivalent checked-in scientific evidence converge on the surviving head. Predecessor checks or reviews do not transfer after a head change.