diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..f70517df8 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] +- **Topic activity analysis-run profile**: cutoff-safe `topic_activity_v1` binds `topic_lineage` dormancy/reactivation and remint refusal (`analysis_engine`). Reactivation is not a new topic; not birth/split/merge and not implemented-main. + - `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..c9bfb2229 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,7 @@ dependencies = [ "sha2", "temporal_core", "tepp_api", + "topic_lineage", "topic_measurement", "uuid", ] diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..3d5aff523 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -20,6 +20,7 @@ serde_json = { workspace = true } sha2 = { workspace = true } tepp_api = { path = "../tepp_api", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } +topic_lineage = { path = "../topic_lineage", version = "0.2.0" } topic_measurement = { path = "../topic_measurement", version = "0.2.0" } uuid.workspace = true diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..9771ab893 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,10 +8,12 @@ //! 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. Topic activity composition +//! invokes [`topic_lineage`] and cannot mint a new identity on reactivation. mod case_deletion_refit; mod lineage_criterion; +mod topic_activity_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -25,6 +27,7 @@ use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, ApiError, }; +use topic_lineage::TopicLineageError; use topic_measurement::TopicMeasurementError; /// One document admitted to exhaustive case-deletion fitting. @@ -46,12 +49,21 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Topic-activity artifact and execution contracts from this engine. +pub use topic_activity_artifact::{ + TOPIC_ACTIVITY_ARTIFACT_BYTE_LIMIT, TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION, + TOPIC_ACTIVITY_MODEL_CONTRACT_VERSION, TOPIC_ACTIVITY_OUTPUT_PROFILE, TopicActivityArtifact, + TopicActivityExecution, TopicActivityInput, TopicActivityTransition, + execute_topic_activity_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, TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, }; +/// Durable topic identity used by topic-activity composition. +pub use topic_lineage::TopicIdentity; /// Topic-lineage artifact and execution contracts from this engine. pub use topic_lineage_artifact::{ TOPIC_LINEAGE_ARTIFACT_BYTE_LIMIT, TOPIC_LINEAGE_ARTIFACT_SCHEMA_VERSION, @@ -248,6 +260,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// Topic activity/dormancy/reactivation refused the offered sequence. + TopicActivity(TopicLineageError), + /// A topic-activity artifact violated its bounded schema or claim boundary. + InvalidTopicActivityArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +278,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::TopicActivity(error) => return error.fmt(formatter), + Self::InvalidTopicActivityArtifact => "invalid topic activity artifact", }; formatter.write_str(message) } @@ -281,6 +299,12 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: TopicLineageError) -> Self { + Self::TopicActivity(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -413,7 +437,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, TopicLineageError, TopicMeasurementError, add_membership_count, + execute_analysis_run, }; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -681,6 +706,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidTopicActivityArtifact, + "invalid topic activity artifact", + ), + ( + AnalysisEngineError::TopicActivity(TopicLineageError::ReactivationIsNotNewTopic), + "reactivation is not a new topic", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,6 +722,12 @@ 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_activity: AnalysisEngineError = + TopicLineageError::InvalidActivityTransition.into(); + assert_eq!( + from_activity.to_string(), + "invalid topic activity transition" + ); assert_eq!( add_membership_count(u64::MAX, 1), Err(AnalysisEngineError::ArithmeticOverflow) diff --git a/crates/analysis_engine/src/topic_activity_artifact.rs b/crates/analysis_engine/src/topic_activity_artifact.rs new file mode 100644 index 000000000..e4628316f --- /dev/null +++ b/crates/analysis_engine/src/topic_activity_artifact.rs @@ -0,0 +1,397 @@ +//! Digest-bound topic activity/dormancy/reactivation as an analysis-run profile. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; +use topic_lineage::{ + TopicIdentity, TopicLineageRecord, identity_recovery_rate, refuse_new_identity_on_reactivation, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; + +/// Versioned schema for a completed topic-activity artifact. +pub const TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION: &str = "tepp.topic_activity.v1"; +/// Model contract required by the topic-activity execution path. +pub const TOPIC_ACTIVITY_MODEL_CONTRACT_VERSION: &str = "topic_activity_v1"; +/// Analysis-run output profile required for a topic-activity artifact. +pub const TOPIC_ACTIVITY_OUTPUT_PROFILE: &str = "topic_activity_v1"; +/// Maximum canonical artifact JSON size. +pub const TOPIC_ACTIVITY_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const TOPIC_ACTIVITY_INFERENCE_STATUS: &str = "reactivation_is_not_new_topic_not_birth_split_merge"; + +/// One fail-closed activity transition applied to a durable topic identity. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TopicActivityTransition { + /// Move an active or reactivated topic into dormancy. + MakeDormant, + /// Reactivate a dormant topic without minting a new identity. + Reactivate, +} + +/// Cutoff-safe topic-activity input bound to one durable identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopicActivityInput { + identity: TopicIdentity, + transitions: Vec, + proposed_reactivation_identity: TopicIdentity, + truth: Vec, + decided: Vec, +} + +impl TopicActivityInput { + /// Construct an activity sequence for one durable topic identity. + #[must_use] + pub fn new( + identity: TopicIdentity, + transitions: Vec, + proposed_reactivation_identity: TopicIdentity, + truth: Vec, + decided: Vec, + ) -> Self { + Self { + identity, + transitions, + proposed_reactivation_identity, + truth, + decided, + } + } + + /// Return the durable topic identity. + #[must_use] + pub const fn identity(&self) -> TopicIdentity { + self.identity + } + + /// Borrow the ordered activity transitions. + #[must_use] + pub fn transitions(&self) -> &[TopicActivityTransition] { + &self.transitions + } + + /// Return the identity proposed at reactivation. + #[must_use] + pub const fn proposed_reactivation_identity(&self) -> TopicIdentity { + self.proposed_reactivation_identity + } + + /// Borrow known-truth identities for recovery-rate scoring. + #[must_use] + pub fn truth(&self) -> &[TopicIdentity] { + &self.truth + } + + /// Borrow decided identities for recovery-rate scoring. + #[must_use] + pub fn decided(&self) -> &[TopicIdentity] { + &self.decided + } +} + +/// Completed, bounded topic-activity result for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicActivityArtifact { + /// 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 activity sequence. + pub knowledge_cutoff: String, + /// Durable P0 topic identity that survived the sequence. + pub topic_identity: String, + /// Final activity wire name (`active`, `dormant`, or `reactivated`). + pub activity: String, + /// Number of applied activity transitions. + pub transition_count: u64, + /// Known-truth identity recovery rate in `[0, 1]`. + pub identity_recovery_rate: f64, + /// Whether reactivation preserved the incumbent identity. + pub reactivation_identity_preserved: bool, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl TopicActivityArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidTopicActivityArtifact`] when the + /// schema, identifiers, activity, rate, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > TOPIC_ACTIVITY_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidTopicActivityArtifact)?; + 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() > TOPIC_ACTIVITY_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 != TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || uuid::Uuid::parse_str(&self.topic_identity).is_err() + || !matches!(self.activity.as_str(), "active" | "dormant" | "reactivated") + || !self.identity_recovery_rate.is_finite() + || self.identity_recovery_rate < 0.0 + || self.identity_recovery_rate > 1.0 + || !self.reactivation_identity_preserved + || self.inference_status != TOPIC_ACTIVITY_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidTopicActivityArtifact); + } + Ok(()) + } +} + +/// One completed topic-activity artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct TopicActivityExecution { + /// Digest-bound completed activity artifact. + pub artifact: TopicActivityArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe topic activity/dormancy/reactivation as one analysis-run profile. +/// +/// The executor invokes [`TopicLineageRecord`] transitions, +/// [`refuse_new_identity_on_reactivation`], and [`identity_recovery_rate`]. +/// Reactivation cannot mint a new topic. This is not topic birth/split/merge, +/// not a Bayesian sampler, and not the fitted `trsl_topic_lineage_v1` profile. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, topic-lineage +/// refusal, or invalid artifact error. +pub fn execute_topic_activity_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + input: &TopicActivityInput, + 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 != TOPIC_ACTIVITY_MODEL_CONTRACT_VERSION + || request.output_profile != TOPIC_ACTIVITY_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let mut record = TopicLineageRecord::active(input.identity()); + for transition in input.transitions() { + record = match transition { + TopicActivityTransition::MakeDormant => record.make_dormant()?, + TopicActivityTransition::Reactivate => record.reactivate()?, + }; + } + refuse_new_identity_on_reactivation(record.identity(), input.proposed_reactivation_identity())?; + let recovery_rate = identity_recovery_rate(input.truth(), input.decided())?; + let artifact = TopicActivityArtifact { + schema_version: TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + topic_identity: record.identity().as_uuid().to_string(), + activity: record.activity().wire_name().to_owned(), + transition_count: input.transitions().len() as u64, + identity_recovery_rate: recovery_rate, + reactivation_identity_preserved: true, + inference_status: TOPIC_ACTIVITY_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = + AnalysisResultSummary::new("topic_activity", 1, 2, TOPIC_ACTIVITY_INFERENCE_STATUS)?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("topic_activity_artifact_{}", &digest[..16]), + digest, + TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(TopicActivityExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + TOPIC_ACTIVITY_ARTIFACT_BYTE_LIMIT, TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION, + TOPIC_ACTIVITY_INFERENCE_STATUS, TopicActivityArtifact, TopicActivityInput, + TopicActivityTransition, + }; + use crate::AnalysisEngineError; + use topic_lineage::TopicIdentity; + use uuid::Uuid; + + fn artifact() -> TopicActivityArtifact { + TopicActivityArtifact { + schema_version: TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + topic_identity: Uuid::from_u128(11).to_string(), + activity: "reactivated".into(), + transition_count: 2, + identity_recovery_rate: 1.0, + reactivation_identity_preserved: true, + inference_status: TOPIC_ACTIVITY_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &TopicActivityArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidTopicActivityArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + TopicActivityArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + TopicActivityArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidTopicActivityArtifact) + ); + assert_eq!( + TopicActivityArtifact::from_json(&"x".repeat(TOPIC_ACTIVITY_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.topic_identity.clear(); + value + }, + { + let mut value = artifact.clone(); + value.activity = "birth".into(); + value + }, + { + let mut value = artifact.clone(); + value.identity_recovery_rate = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.identity_recovery_rate = -0.1; + value + }, + { + let mut value = artifact.clone(); + value.identity_recovery_rate = 1.5; + value + }, + { + let mut value = artifact.clone(); + value.reactivation_identity_preserved = false; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } + + #[test] + fn input_accessors_expose_identity_and_transitions() { + let identity = TopicIdentity::from_uuid(Uuid::from_u128(11)); + let input = TopicActivityInput::new( + identity, + vec![ + TopicActivityTransition::MakeDormant, + TopicActivityTransition::Reactivate, + ], + identity, + vec![identity], + vec![identity], + ); + assert_eq!(input.identity(), identity); + assert_eq!(input.transitions().len(), 2); + assert_eq!(input.proposed_reactivation_identity(), identity); + assert_eq!(input.truth(), &[identity]); + assert_eq!(input.decided(), &[identity]); + } +} diff --git a/crates/analysis_engine/tests/topic_activity_execution_contract.rs b/crates/analysis_engine/tests/topic_activity_execution_contract.rs new file mode 100644 index 000000000..9a6a0a522 --- /dev/null +++ b/crates/analysis_engine/tests/topic_activity_execution_contract.rs @@ -0,0 +1,213 @@ +//! End-to-end contract for cutoff-safe topic activity/dormancy/reactivation. + +use analysis_engine::{ + AnalysisEngineError, TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION, + TOPIC_ACTIVITY_MODEL_CONTRACT_VERSION, TOPIC_ACTIVITY_OUTPUT_PROFILE, TopicActivityInput, + TopicActivityTransition, execute_topic_activity_run, +}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use topic_lineage::{TopicIdentity, TopicLineageError}; +use uuid::Uuid; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-02-01T00:00:00Z").expect("cutoff") +} + +fn identity() -> TopicIdentity { + TopicIdentity::from_uuid(Uuid::from_u128(11)) +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "topic-activity-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-topic-activity".into(), + knowledge_cutoff: "2026-02-01T00:00:00Z".into(), + model_contract_version: TOPIC_ACTIVITY_MODEL_CONTRACT_VERSION.into(), + output_profile: TOPIC_ACTIVITY_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-topic-activity", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn recovered_input() -> TopicActivityInput { + let identity = identity(); + TopicActivityInput::new( + identity, + vec![ + TopicActivityTransition::MakeDormant, + TopicActivityTransition::Reactivate, + ], + identity, + vec![identity, identity, identity], + vec![identity, identity, identity], + ) +} + +fn execute( + request: &AnalysisRunRequest, + input: &TopicActivityInput, +) -> Result { + execute_topic_activity_run( + request, + &accepted(request), + "snapshot-topic-activity", + cutoff(), + input, + "2026-02-02T00:00:00Z", + ) +} + +#[test] +fn dormancy_then_reactivation_keeps_identity_and_unit_recovery() { + let request = request(); + let execution = execute(&request, &recovered_input()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!( + execution.artifact.topic_identity, + identity().as_uuid().to_string() + ); + assert_eq!(execution.artifact.activity, "reactivated"); + assert_eq!(execution.artifact.transition_count, 2); + assert!((execution.artifact.identity_recovery_rate - 1.0).abs() < f64::EPSILON); + assert!(execution.artifact.reactivation_identity_preserved); + assert_eq!( + execution.artifact.inference_status, + "reactivation_is_not_new_topic_not_birth_split_merge" + ); + 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(TOPIC_ACTIVITY_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn reminted_identity_and_illegal_transitions_fail_closed() { + let request = request(); + let identity = identity(); + let reminted = TopicActivityInput::new( + identity, + vec![ + TopicActivityTransition::MakeDormant, + TopicActivityTransition::Reactivate, + ], + TopicIdentity::from_uuid(Uuid::from_u128(99)), + vec![identity], + vec![identity], + ); + assert_eq!( + execute(&request, &reminted), + Err(AnalysisEngineError::TopicActivity( + TopicLineageError::ReactivationIsNotNewTopic + )) + ); + let double_dormant = TopicActivityInput::new( + identity, + vec![ + TopicActivityTransition::MakeDormant, + TopicActivityTransition::MakeDormant, + ], + identity, + vec![identity], + vec![identity], + ); + assert_eq!( + execute(&request, &double_dormant), + Err(AnalysisEngineError::TopicActivity( + TopicLineageError::InvalidActivityTransition + )) + ); + let empty_truth = TopicActivityInput::new( + identity, + vec![TopicActivityTransition::MakeDormant], + identity, + Vec::new(), + Vec::new(), + ); + assert_eq!( + execute(&request, &empty_truth), + Err(AnalysisEngineError::TopicActivity( + TopicLineageError::InvalidIdentityPayload + )) + ); +} + +#[test] +fn minted_replacements_record_lower_recovery_than_stable_identity() { + let request = request(); + let identity = identity(); + let other = TopicIdentity::from_uuid(Uuid::from_u128(4)); + let minted = TopicActivityInput::new( + identity, + vec![ + TopicActivityTransition::MakeDormant, + TopicActivityTransition::Reactivate, + ], + identity, + vec![identity, identity, identity], + vec![identity, identity, other], + ); + let execution = execute(&request, &minted).expect("execution"); + assert!((execution.artifact.identity_recovery_rate - (2.0 / 3.0)).abs() < 1e-12); + assert_eq!(execution.artifact.activity, "reactivated"); + assert!(execution.artifact.reactivation_identity_preserved); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + assert_eq!( + execute_topic_activity_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &recovered_input(), + "2026-02-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 = "trsl_topic_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "fitted_candidate_k_v1".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request, &recovered_input()), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..2f5147c81 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -62,6 +62,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | `topic_lineage` activity/dormancy/reactivation identity on the active product branch; birth/split/merge remain later extensions | partial | +| topic activity analysis-run composition | ADR 0012/0022/0051 | `analysis_engine` `topic_activity_v1` binds `topic_lineage` dormancy/reactivation and remint refusal; not birth/split/merge, not a Bayesian sampler, and not implemented-main | active-PR | | no default stopword deletion / no TF-IDF-BM25 inferential weighting | ADR 0004/0012; PRD/TRD | `topic_measurement::refuse_lexical_inferential_weight` on the active PR; preprocessing pipeline remaining | partial | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | future topic lineage/activity state | accepted-target | diff --git a/docs/adr/0051-topic-activity-analysis-run.md b/docs/adr/0051-topic-activity-analysis-run.md new file mode 100644 index 000000000..ada16a6d1 --- /dev/null +++ b/docs/adr/0051-topic-activity-analysis-run.md @@ -0,0 +1,79 @@ +# ADR 0051 — Topic activity/dormancy/reactivation 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 0012 (P0 topic identity / activity) 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 keeps a global P0 topic identity across activity, +dormancy, and reactivation inside `topic_lineage`. Operators still cannot +request that identity contract as a digest-bound analysis-run output. +Fitted same-topic sequence edges are a different profile +(`trsl_topic_lineage_v1`). Schwarz fitted candidate-`K` is a different +profile. Full Bayesian sampling, GPU, and topic birth/split/merge remain +later GAP-004 work and are not this slice. + +Reactivation must not mint a new topic identity. An LLM label cannot define +topic identity. + +## Decision + +Add the `topic_activity_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes a durable `TopicIdentity` plus ordered activity transitions; +- requires the request snapshot and knowledge cutoff to match the offered + construction; +- invokes `TopicLineageRecord` dormancy/reactivation, + `refuse_new_identity_on_reactivation`, and `identity_recovery_rate` + without reimplementing those gates; +- emits a canonical SHA-256-digested `tepp.topic_activity.v1` artifact with + the surviving identity, final activity, transition count, recovery rate, + and inference status `reactivation_is_not_new_topic_not_birth_split_merge`; +- does not invent a Bayesian sampler, persist rows, split or merge topics, + or emit fitted sequence edges. + +This is activity-state identity, not birth/split/merge and not fitted +topic-lineage edges. + +## Alternatives considered + +1. Invent a Bayesian sampler or birth/split/merge engine — rejected because + those functions do not exist on protected main and must not be faked. +2. Bind the Pareto `select_candidate_k` gate — rejected for this slice + because it is a different model-selection surface from activity identity. +3. Bind GAP-013 interpreter/verifier — already live as a separate analysis-run + profile. +4. Bind existing `topic_lineage` activity/dormancy/reactivation to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe topic activity as a digest-bound terminal +result. The artifact does not claim Bayesian sampling, GPU parity, or topic +birth/split/merge. Snapshot/profile/cutoff mismatch, illegal transitions, +reminted reactivation identities, and invalid recovery payloads fail closed. + +## Verification + +The PR includes Rust unit and integration tests for dormancy-then-reactivation +identity preservation, remint refusal, illegal transitions, minted-replacement +recovery rates, 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 `topic_activity_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps reactivation +from minting a new topic identity and keeps activity distinct from +birth/split/merge. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..3d123d6fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | +| [0051](0051-topic-activity-analysis-run.md) | Topic activity/dormancy/reactivation as an analysis-run profile | Accepted | active-PR | Complements ADR 0012/0022; reactivation is not a new topic and not birth/split/merge. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **topic activity/dormancy/reactivation analysis-run claim boundary:** ADR 0051. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/topic-activity-analysis-run.md b/docs/doctoring/topic-activity-analysis-run.md new file mode 100644 index 000000000..06e120512 --- /dev/null +++ b/docs/doctoring/topic-activity-analysis-run.md @@ -0,0 +1,17 @@ +# Topic activity/dormancy/reactivation analysis-run composition + +**Active slice:** ADR 0051 / `topic_activity_v1` +**Protected-main status:** not implemented-main + +`topic_lineage` already keeps a global P0 topic identity across activity, +dormancy, and reactivation. This slice binds that contract to a cutoff-safe +analysis-run profile so an operator can request a digest-bound terminal +result. + +The executor refuses reminted reactivation identities and illegal activity +transitions. It is not a Bayesian sampler, not GPU execution, not topic +birth/split/merge, and not the fitted `trsl_topic_lineage_v1` sequence-edge +profile. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim.