diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..aa38ff944 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 the versioned TDT/CHRONOS composition (`compose_event_intelligence`) to the `tdt_chronos_workflow_v1` analysis-run output profile. Mentions unavailable at the request cutoff are excluded; the digest-bound `tepp.tdt_chronos_workflow.v1` artifact records detection versus prediction layers and refuses composition-as-instance/transition. This is not a new extractor, not persistence, and not a promoted event instance. + - `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..6acfde05a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,7 @@ version = "0.2.0" dependencies = [ "corpus_split", "event_core", + "evidence_core", "membership_core", "relation_graph", "serde", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..ac56f77b7 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -25,6 +25,7 @@ uuid.workspace = true [dev-dependencies] corpus_split = { path = "../corpus_split", version = "0.2.0" } +evidence_core = { path = "../evidence_core", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } diff --git a/crates/analysis_engine/src/event_intelligence_artifact.rs b/crates/analysis_engine/src/event_intelligence_artifact.rs new file mode 100644 index 000000000..eb58749ea --- /dev/null +++ b/crates/analysis_engine/src/event_intelligence_artifact.rs @@ -0,0 +1,546 @@ +//! Digest-bound TDT/CHRONOS workflow artifacts from ADR 0016 composition. + +use std::collections::BTreeSet; + +use event_core::{ + ChronosOccurrenceForecast, EVENT_INTELLIGENCE_WORKFLOW_VERSION, EventError, EventEvidenceLayer, + EventIntelligenceWorkflowConfig, EventLinkPair, EventMention, EventTrackAssignment, + FirstStoryLabel, SchemaSlotAssignment, StorySegmentation, compose_event_intelligence, + refuse_composition_as_instance, refuse_composition_as_transition, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; + +/// Versioned schema for a completed TDT/CHRONOS workflow artifact. +pub const EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION: &str = "tepp.tdt_chronos_workflow.v1"; +/// Model contract required by the versioned event-intelligence workflow. +pub const EVENT_INTELLIGENCE_MODEL_CONTRACT_VERSION: &str = "event_intelligence_workflow_v1"; +/// Analysis-run output profile required for a TDT/CHRONOS workflow artifact. +pub const EVENT_INTELLIGENCE_OUTPUT_PROFILE: &str = "tdt_chronos_workflow_v1"; +/// Maximum canonical artifact JSON size. +pub const EVENT_INTELLIGENCE_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const EVENT_INTELLIGENCE_INFERENCE_STATUS: &str = "composed_workflow_not_instance_or_transition"; + +/// Already-extracted TDT/CHRONOS artifacts admitted to one analysis run. +/// +/// This input is not an extractor. Callers supply validated mentions, links, +/// first-story labels, tracks, schema slots, and forecasts. +#[derive(Clone, Debug, PartialEq)] +pub struct EventIntelligenceRunInput { + config: EventIntelligenceWorkflowConfig, + segmentation: StorySegmentation, + mentions: Vec, + links: Vec, + first_story_labels: Vec, + track_assignments: Vec, + schema_slot_assignments: Vec, + occurrence_forecasts: Vec, +} + +impl EventIntelligenceRunInput { + /// Bundle already-extracted TDT/CHRONOS artifacts for one analysis run. + #[must_use] + #[allow(clippy::too_many_arguments, reason = "audited TDT/CHRONOS sequence")] + pub fn new( + config: EventIntelligenceWorkflowConfig, + segmentation: StorySegmentation, + mentions: Vec, + links: Vec, + first_story_labels: Vec, + track_assignments: Vec, + schema_slot_assignments: Vec, + occurrence_forecasts: Vec, + ) -> Self { + Self { + config, + segmentation, + mentions, + links, + first_story_labels, + track_assignments, + schema_slot_assignments, + occurrence_forecasts, + } + } + + /// Return the workflow configuration. + #[must_use] + pub const fn config(&self) -> EventIntelligenceWorkflowConfig { + self.config + } + + /// Return the admitted story segmentation. + #[must_use] + pub const fn segmentation(&self) -> &StorySegmentation { + &self.segmentation + } + + /// Return the offered mentions before cutoff filtering. + #[must_use] + pub fn mentions(&self) -> &[EventMention] { + &self.mentions + } + + /// Return the offered TDT links before cutoff filtering. + #[must_use] + pub fn links(&self) -> &[EventLinkPair] { + &self.links + } + + /// Return the offered first-story labels before cutoff filtering. + #[must_use] + pub fn first_story_labels(&self) -> &[FirstStoryLabel] { + &self.first_story_labels + } + + /// Return the offered track assignments before cutoff filtering. + #[must_use] + pub fn track_assignments(&self) -> &[EventTrackAssignment] { + &self.track_assignments + } + + /// Return the offered CHRONOS schema-slot assignments. + #[must_use] + pub fn schema_slot_assignments(&self) -> &[SchemaSlotAssignment] { + &self.schema_slot_assignments + } + + /// Return the offered CHRONOS occurrence forecasts. + #[must_use] + pub fn occurrence_forecasts(&self) -> &[ChronosOccurrenceForecast] { + &self.occurrence_forecasts + } +} + +/// Completed, bounded TDT/CHRONOS workflow result consumed by analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EventIntelligenceArtifact { + /// 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 analysis run. + pub knowledge_cutoff: String, + /// Versioned TDT/CHRONOS workflow identity. + pub workflow_version: u16, + /// Mentions admitted at the request cutoff. + pub mention_count: u64, + /// Mentions excluded because availability was after the request cutoff. + pub excluded_after_cutoff_count: u64, + /// TDT links whose both mentions remained eligible. + pub link_count: u64, + /// First-story labels aligned to admitted mentions. + pub first_story_count: u64, + /// Track assignments aligned to admitted mentions. + pub track_count: u64, + /// CHRONOS schema-slot assignments admitted with the workflow. + pub schema_slot_count: u64, + /// CHRONOS occurrence forecasts admitted with the workflow. + pub forecast_count: u64, + /// Epistemic layer of the composed TDT envelope. + pub envelope_layer: String, + /// Epistemic layer retained by composed CHRONOS hypotheses. + pub hypothesis_layer: String, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl EventIntelligenceArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEventIntelligenceArtifact`] when the + /// schema, identifiers, counts, layers, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > EVENT_INTELLIGENCE_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidEventIntelligenceArtifact)?; + 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)?; + 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 != EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.workflow_version != EVENT_INTELLIGENCE_WORKFLOW_VERSION + || self.mention_count == 0 + || self.first_story_count != self.mention_count + || self.track_count != self.mention_count + || self.envelope_layer != EventEvidenceLayer::TdtDetection.wire_name() + || self.hypothesis_layer != EventEvidenceLayer::ChronosPrediction.wire_name() + || self.inference_status != EVENT_INTELLIGENCE_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidEventIntelligenceArtifact); + } + Ok(()) + } +} + +/// One completed event-intelligence artifact and its request-bound terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct EventIntelligenceExecution { + /// Digest-bound completed workflow artifact. + pub artifact: EventIntelligenceArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe TDT/CHRONOS composition as one analysis-run profile. +/// +/// The caller supplies already-extracted artifacts. This executor does not +/// invent a new extractor, persist the composition, or promote it to an event +/// instance or state transition. Mentions whose availability is later than the +/// request cutoff are excluded before composition. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, composition +/// failure, or invalid/oversized artifact error. +#[expect( + clippy::missing_panics_doc, + reason = "validated composition and bounded constants cannot fail" +)] +pub fn execute_event_intelligence_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + input: EventIntelligenceRunInput, + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + require_event_intelligence_binding(request, snapshot_id, knowledge_cutoff)?; + let admitted = admit_mentions_at_cutoff(input, knowledge_cutoff)?; + let composition = compose_event_intelligence( + admitted.config, + admitted.segmentation, + admitted.mentions, + admitted.links, + admitted.first_story_labels, + admitted.track_assignments, + admitted.schema_slot_assignments, + admitted.occurrence_forecasts, + )?; + let _ = refuse_composition_as_instance(&composition); + let _ = refuse_composition_as_transition(&composition); + let artifact = EventIntelligenceArtifact::from_composition( + accepted, + snapshot_id, + knowledge_cutoff, + admitted.excluded_after_cutoff_count, + &composition, + ) + .expect("validated composition produces a valid bounded artifact"); + let digest = artifact.sha256()?; + let statistic_count = artifact + .mention_count + .checked_add(artifact.link_count) + .and_then(|value| value.checked_add(artifact.schema_slot_count)) + .and_then(|value| value.checked_add(artifact.forecast_count)) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let summary = AnalysisResultSummary::new( + "tdt_chronos_workflow", + artifact.mention_count, + statistic_count, + EVENT_INTELLIGENCE_INFERENCE_STATUS, + ) + .expect("bounded event-intelligence summary constants are valid"); + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("event_intelligence_artifact_{}", &digest[..16]), + digest, + EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(EventIntelligenceExecution { + artifact, + terminal_result, + }) +} + +fn require_event_intelligence_binding( + request: &AnalysisRunRequest, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, +) -> Result<(), AnalysisEngineError> { + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != EVENT_INTELLIGENCE_MODEL_CONTRACT_VERSION + || request.output_profile != EVENT_INTELLIGENCE_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(()) +} + +struct AdmittedEventIntelligence { + config: EventIntelligenceWorkflowConfig, + segmentation: StorySegmentation, + mentions: Vec, + links: Vec, + first_story_labels: Vec, + track_assignments: Vec, + schema_slot_assignments: Vec, + occurrence_forecasts: Vec, + excluded_after_cutoff_count: u64, +} + +fn admit_mentions_at_cutoff( + input: EventIntelligenceRunInput, + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + if input.first_story_labels.len() != input.mentions.len() + || input.track_assignments.len() != input.mentions.len() + { + return Err(AnalysisEngineError::Event(EventError::InvalidWirePayload)); + } + let mut mentions = Vec::new(); + let mut first_story_labels = Vec::new(); + let mut track_assignments = Vec::new(); + let mut excluded_after_cutoff_count = 0_u64; + for ((mention, first_story), track) in input + .mentions + .into_iter() + .zip(input.first_story_labels) + .zip(input.track_assignments) + { + if mention.clocks().available_time().instant() <= knowledge_cutoff.instant() { + mentions.push(mention); + first_story_labels.push(first_story); + track_assignments.push(track); + } else { + excluded_after_cutoff_count += 1; + } + } + if mentions.is_empty() { + return Err(AnalysisEngineError::Event( + EventError::MentionIneligibleAtCutoff, + )); + } + let eligible_ids: BTreeSet<_> = mentions.iter().map(EventMention::mention_id).collect(); + let links: Vec<_> = input + .links + .into_iter() + .filter(|link| eligible_ids.contains(&link.left()) & eligible_ids.contains(&link.right())) + .collect(); + Ok(AdmittedEventIntelligence { + config: input.config, + segmentation: input.segmentation, + mentions, + links, + first_story_labels, + track_assignments, + schema_slot_assignments: input.schema_slot_assignments, + occurrence_forecasts: input.occurrence_forecasts, + excluded_after_cutoff_count, + }) +} + +fn count_or_overflow(len: usize) -> Result { + u64::try_from(len).map_err(|_| AnalysisEngineError::ArithmeticOverflow) +} + +impl EventIntelligenceArtifact { + fn from_composition( + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + excluded_after_cutoff_count: u64, + composition: &event_core::EventIntelligenceComposition, + ) -> Result { + let artifact = Self { + schema_version: EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + workflow_version: composition.config_version(), + mention_count: count_or_overflow(composition.mentions().len())?, + excluded_after_cutoff_count, + link_count: count_or_overflow(composition.links().len())?, + first_story_count: count_or_overflow(composition.first_story_labels().len())?, + track_count: count_or_overflow(composition.track_assignments().len())?, + schema_slot_count: count_or_overflow(composition.schema_slot_assignments().len())?, + forecast_count: count_or_overflow(composition.occurrence_forecasts().len())?, + envelope_layer: composition.evidence_layer().wire_name().into(), + hypothesis_layer: composition.chronos_evidence_layer().wire_name().into(), + inference_status: EVENT_INTELLIGENCE_INFERENCE_STATUS.into(), + }; + artifact.validate()?; + Ok(artifact) + } +} + +#[cfg(test)] +mod tests { + use super::{ + EVENT_INTELLIGENCE_ARTIFACT_BYTE_LIMIT, EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION, + EVENT_INTELLIGENCE_INFERENCE_STATUS, EventIntelligenceArtifact, + }; + use crate::AnalysisEngineError; + use event_core::EventEvidenceLayer; + + fn artifact() -> EventIntelligenceArtifact { + EventIntelligenceArtifact { + schema_version: EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-03-31T00:00:00Z".into(), + workflow_version: 1, + mention_count: 3, + excluded_after_cutoff_count: 0, + link_count: 2, + first_story_count: 3, + track_count: 3, + schema_slot_count: 2, + forecast_count: 1, + envelope_layer: EventEvidenceLayer::TdtDetection.wire_name().into(), + hypothesis_layer: EventEvidenceLayer::ChronosPrediction.wire_name().into(), + inference_status: EVENT_INTELLIGENCE_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &EventIntelligenceArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidEventIntelligenceArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + EventIntelligenceArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + EventIntelligenceArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidEventIntelligenceArtifact) + ); + assert_eq!( + EventIntelligenceArtifact::from_json( + &"x".repeat(EVENT_INTELLIGENCE_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.workflow_version = 0; + value + }, + { + let mut value = artifact.clone(); + value.mention_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.first_story_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.track_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.envelope_layer = EventEvidenceLayer::PromotedTransition.wire_name().into(); + value + }, + { + let mut value = artifact.clone(); + value.hypothesis_layer = EventEvidenceLayer::PromotedTransition.wire_name().into(); + value + }, + { + let mut value = artifact.clone(); + value.envelope_layer.clear(); + value + }, + { + let mut value = artifact.clone(); + value.hypothesis_layer.clear(); + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..6471e1646 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,13 +8,17 @@ //! 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. The `tdt_chronos_workflow_v1` +//! profile binds ADR 0016 composition without inventing an extractor or promoting +//! the workflow to an event instance or state transition. mod case_deletion_refit; +mod event_intelligence_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; +use event_core::EventError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -41,6 +45,13 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Digest-bound TDT/CHRONOS workflow artifact and execution contracts. +pub use event_intelligence_artifact::{ + EVENT_INTELLIGENCE_ARTIFACT_BYTE_LIMIT, EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION, + EVENT_INTELLIGENCE_MODEL_CONTRACT_VERSION, EVENT_INTELLIGENCE_OUTPUT_PROFILE, + EventIntelligenceArtifact, EventIntelligenceExecution, EventIntelligenceRunInput, + execute_event_intelligence_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +259,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// Event-intelligence composition or cutoff filtering failed closed. + Event(EventError), + /// A TDT/CHRONOS workflow artifact violated its bounded schema or claim boundary. + InvalidEventIntelligenceArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +277,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::Event(error) => return error.fmt(formatter), + Self::InvalidEventIntelligenceArtifact => "invalid event intelligence artifact", }; formatter.write_str(message) } @@ -281,6 +298,12 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: EventError) -> Self { + Self::Event(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -681,6 +704,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::Event(event_core::EventError::InvalidWirePayload), + "invalid event wire payload", + ), + ( + AnalysisEngineError::InvalidEventIntelligenceArtifact, + "invalid event intelligence artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,6 +720,8 @@ 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_event: AnalysisEngineError = event_core::EventError::InvalidWirePayload.into(); + assert_eq!(from_event.to_string(), "invalid event wire payload"); assert_eq!( add_membership_count(u64::MAX, 1), Err(AnalysisEngineError::ArithmeticOverflow) diff --git a/crates/analysis_engine/tests/event_intelligence_execution_contract.rs b/crates/analysis_engine/tests/event_intelligence_execution_contract.rs new file mode 100644 index 000000000..e5f3a6875 --- /dev/null +++ b/crates/analysis_engine/tests/event_intelligence_execution_contract.rs @@ -0,0 +1,547 @@ +//! End-to-end contract for the completed TDT/CHRONOS workflow artifact. + +use analysis_engine::{ + AnalysisEngineError, EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION, + EVENT_INTELLIGENCE_MODEL_CONTRACT_VERSION, EVENT_INTELLIGENCE_OUTPUT_PROFILE, + EventIntelligenceRunInput, execute_event_intelligence_run, +}; +use event_core::{ + ChronosOccurrenceForecast, ChronosPredictionId, EVENT_INTELLIGENCE_WORKFLOW_VERSION, + EventConfidence, EventError, EventEvidenceLayer, EventIntelligenceWorkflowConfig, + EventLinkPair, EventMention, EventRoleKind, EventTrackAssignment, EventTrackId, + FirstStoryLabel, MentionEvidenceClocks, MentionReviewStatus, SchemaSlotAssignment, + StorySegmentation, +}; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +const STORY_A: &str = "The procurement office awarded the river-crossing contract on 1 March 2026 after the earlier protest was withdrawn."; +const STORY_A_NOISY: &str = + "Procurement office awarded river-crossing contract 1 March 2026; earlier protest withdrawn."; +const STORY_A_REVISED: &str = "Revised notice: the procurement office awarded the river-crossing contract on 1 March 2026 after the earlier protest was withdrawn."; + +fn record(text: &str) -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), text).expect("document") +} + +fn span_for(document: &DocumentRecord, surface: &str) -> SourceSpan { + let byte_start = document.text().find(surface).expect("surface present"); + let byte_end = byte_start + surface.len(); + let scalar_start = document.text()[..byte_start].chars().count(); + let scalar_end = scalar_start + surface.chars().count(); + SourceSpan::new( + document, + byte_start, + byte_end, + scalar_start, + scalar_end, + None, + ) + .expect("span") +} + +fn clocks_at(available: &str) -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T12:00:00Z").expect("event"), + AssertionTime::parse_rfc3339(available).expect("assertion"), + DocumentTime::parse_rfc3339(available).expect("document"), + SystemTime::parse_rfc3339(available).expect("system"), + AvailableTime::parse_rfc3339(available).expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("clocks") +} + +fn grounded( + document: &DocumentRecord, + surface: &str, + available: &str, + confidence: f64, +) -> EventMention { + EventMention::new( + document, + span_for(document, surface), + EventConfidence::new(confidence).expect("confidence"), + clocks_at(available), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("grounded mention") +} + +fn half() -> EventConfidence { + EventConfidence::new(0.5).expect("half") +} + +fn workflow_config() -> EventIntelligenceWorkflowConfig { + EventIntelligenceWorkflowConfig::new( + EVENT_INTELLIGENCE_WORKFLOW_VERSION, + half(), + half(), + half(), + half(), + half(), + half(), + ) + .expect("workflow config") +} + +struct KnownTruthFixture { + award_original: EventMention, + protest_original: EventMention, + award_noisy: EventMention, + award_revised: EventMention, +} + +impl KnownTruthFixture { + fn build() -> Self { + let original = record(STORY_A); + let noisy = record(STORY_A_NOISY); + let revised = record(STORY_A_REVISED); + let award_original = grounded( + &original, + "awarded the river-crossing contract", + "2026-03-02T09:00:00Z", + 0.91, + ); + let protest_original = grounded(&original, "protest", "2026-03-02T09:00:00Z", 0.88); + let award_noisy = grounded( + &noisy, + "awarded river-crossing contract", + "2026-03-02T12:00:00Z", + 0.80, + ); + let award_revised = grounded( + &revised, + "awarded the river-crossing contract", + "2026-03-10T08:00:00Z", + 0.93, + ); + Self { + award_original, + protest_original, + award_noisy, + award_revised, + } + } + + fn mentions(&self) -> Vec { + vec![ + self.award_original.clone(), + self.protest_original.clone(), + self.award_noisy.clone(), + ] + } + + fn links(&self) -> Vec { + vec![ + EventLinkPair::new( + self.award_original.mention_id(), + self.award_noisy.mention_id(), + ) + .expect("duplicate link"), + EventLinkPair::new( + self.award_original.mention_id(), + self.protest_original.mention_id(), + ) + .expect("same-document link"), + ] + } + + fn first_story_labels() -> Vec { + vec![ + FirstStoryLabel::FirstStory, + FirstStoryLabel::FollowUp, + FirstStoryLabel::FollowUp, + ] + } + + fn track_assignments(&self) -> Vec { + vec![ + EventTrackAssignment::new(self.award_original.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new( + self.protest_original.mention_id(), + EventTrackId::from_raw(1), + ), + EventTrackAssignment::new(self.award_noisy.mention_id(), EventTrackId::from_raw(1)), + ] + } + + fn schema_slots() -> Vec { + vec![ + SchemaSlotAssignment::new(EventRoleKind::Agent, "procurement office").expect("agent"), + SchemaSlotAssignment::new(EventRoleKind::Product, "river-crossing contract") + .expect("product"), + ] + } + + fn forecasts() -> Vec { + vec![ChronosOccurrenceForecast::new( + ChronosPredictionId::from_raw(1), + EventConfidence::new(0.75).expect("forecast"), + )] + } + + fn input(&self) -> EventIntelligenceRunInput { + EventIntelligenceRunInput::new( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("segmentation"), + self.mentions(), + self.links(), + Self::first_story_labels(), + self.track_assignments(), + Self::schema_slots(), + Self::forecasts(), + ) + } +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "event-intelligence-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-event-intelligence".into(), + knowledge_cutoff: "2026-03-31T00:00:00Z".into(), + model_contract_version: EVENT_INTELLIGENCE_MODEL_CONTRACT_VERSION.into(), + output_profile: EVENT_INTELLIGENCE_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-event-intelligence", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff") +} + +#[test] +fn composed_workflow_emits_digest_bound_cutoff_safe_counts() { + let fixture = KnownTruthFixture::build(); + let request = request(); + let accepted = accepted(&request); + let input = fixture.input(); + assert_eq!( + input.config().version(), + EVENT_INTELLIGENCE_WORKFLOW_VERSION + ); + assert_eq!(input.mentions().len(), 3); + assert_eq!(input.links().len(), 2); + assert_eq!(input.first_story_labels().len(), 3); + assert_eq!(input.track_assignments().len(), 3); + assert_eq!(input.schema_slot_assignments().len(), 2); + assert_eq!(input.occurrence_forecasts().len(), 1); + assert_eq!(input.segmentation().unit_count(), 3); + + let execution = execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + input, + "2026-04-01T00:00:00Z", + ) + .expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.mention_count, 3); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); + assert_eq!(execution.artifact.link_count, 2); + assert_eq!(execution.artifact.first_story_count, 3); + assert_eq!(execution.artifact.track_count, 3); + assert_eq!(execution.artifact.schema_slot_count, 2); + assert_eq!(execution.artifact.forecast_count, 1); + assert_eq!( + execution.artifact.envelope_layer, + EventEvidenceLayer::TdtDetection.wire_name() + ); + assert_eq!( + execution.artifact.hypothesis_layer, + EventEvidenceLayer::ChronosPrediction.wire_name() + ); + assert_ne!( + execution.artifact.envelope_layer, + EventEvidenceLayer::PromotedTransition.wire_name() + ); + assert_eq!( + execution.artifact.inference_status, + "composed_workflow_not_instance_or_transition" + ); + 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(EVENT_INTELLIGENCE_ARTIFACT_SCHEMA_VERSION) + ); + assert!(execution.artifact.to_json().is_ok()); +} + +#[test] +fn execution_excludes_mentions_unavailable_at_the_request_cutoff() { + let fixture = KnownTruthFixture::build(); + let mut request = request(); + request.knowledge_cutoff = "2026-03-05T00:00:00Z".into(); + let accepted = accepted(&request); + let early_cutoff = KnowledgeCutoff::parse_rfc3339("2026-03-05T00:00:00Z").expect("cutoff"); + let input = EventIntelligenceRunInput::new( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("segmentation"), + vec![ + fixture.award_original.clone(), + fixture.protest_original.clone(), + fixture.award_noisy.clone(), + fixture.award_revised.clone(), + ], + { + let mut links = fixture.links(); + links.push( + EventLinkPair::new( + fixture.award_original.mention_id(), + fixture.award_revised.mention_id(), + ) + .expect("revised link"), + ); + links + }, + vec![ + FirstStoryLabel::FirstStory, + FirstStoryLabel::FollowUp, + FirstStoryLabel::FollowUp, + FirstStoryLabel::FollowUp, + ], + vec![ + EventTrackAssignment::new( + fixture.award_original.mention_id(), + EventTrackId::from_raw(1), + ), + EventTrackAssignment::new( + fixture.protest_original.mention_id(), + EventTrackId::from_raw(1), + ), + EventTrackAssignment::new(fixture.award_noisy.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new( + fixture.award_revised.mention_id(), + EventTrackId::from_raw(1), + ), + ], + KnownTruthFixture::schema_slots(), + KnownTruthFixture::forecasts(), + ); + let execution = execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + early_cutoff, + input, + "2026-04-01T00:00:00Z", + ) + .expect("execution"); + assert_eq!(execution.artifact.mention_count, 3); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); + assert_eq!(execution.artifact.link_count, 2); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let fixture = KnownTruthFixture::build(); + let request = request(); + let accepted = accepted(&request); + + assert_eq!( + execute_event_intelligence_run( + &request, + &accepted, + "other-snapshot", + cutoff(), + fixture.input(), + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-03-05T00: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_event_intelligence_run( + &invalid_request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + fixture.input(), + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn execution_refuses_stream_misalignment() { + let fixture = KnownTruthFixture::build(); + let request = request(); + let accepted = accepted(&request); + + let mismatched_labels = EventIntelligenceRunInput::new( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("segmentation"), + fixture.mentions(), + fixture.links(), + vec![FirstStoryLabel::FirstStory], + fixture.track_assignments(), + KnownTruthFixture::schema_slots(), + KnownTruthFixture::forecasts(), + ); + assert_eq!( + execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + mismatched_labels, + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::Event(EventError::InvalidWirePayload)) + ); + + let mismatched_tracks = EventIntelligenceRunInput::new( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("segmentation"), + fixture.mentions(), + fixture.links(), + KnownTruthFixture::first_story_labels(), + vec![EventTrackAssignment::new( + fixture.award_original.mention_id(), + EventTrackId::from_raw(1), + )], + KnownTruthFixture::schema_slots(), + KnownTruthFixture::forecasts(), + ); + assert_eq!( + execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + mismatched_tracks, + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::Event(EventError::InvalidWirePayload)) + ); +} + +#[test] +fn execution_refuses_empty_cutoff_receipt_mismatch_and_compose_failure() { + let fixture = KnownTruthFixture::build(); + let request = request(); + let accepted = accepted(&request); + let input = fixture.input(); + let mut early_request = request.clone(); + early_request.knowledge_cutoff = "2026-03-01T00:00:00Z".into(); + let too_early = KnowledgeCutoff::parse_rfc3339("2026-03-01T00:00:00Z").expect("cutoff"); + assert_eq!( + execute_event_intelligence_run( + &early_request, + &accepted, + "snapshot-event-intelligence", + too_early, + input, + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::Event( + EventError::MentionIneligibleAtCutoff + )) + ); + + let wrong_receipt = AnalysisRunAccepted::new("run-event-intelligence", "accepted", "other-key") + .expect("accepted"); + assert_eq!( + execute_event_intelligence_run( + &request, + &wrong_receipt, + "snapshot-event-intelligence", + cutoff(), + fixture.input(), + "2026-04-01T00:00:00Z", + ) + .expect_err("receipt"), + AnalysisEngineError::Api(tepp_api::ApiError::InvalidWirePayload) + ); + + let mut tracks = fixture.track_assignments(); + tracks[0] = + EventTrackAssignment::new(fixture.award_noisy.mention_id(), EventTrackId::from_raw(1)); + let misaligned_track = EventIntelligenceRunInput::new( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("segmentation"), + fixture.mentions(), + fixture.links(), + KnownTruthFixture::first_story_labels(), + tracks, + KnownTruthFixture::schema_slots(), + KnownTruthFixture::forecasts(), + ); + assert_eq!( + execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + misaligned_track, + "2026-04-01T00:00:00Z", + ), + Err(AnalysisEngineError::Event(EventError::InvalidWirePayload)) + ); +} + +#[test] +fn execution_refuses_invalid_completion_time() { + let fixture = KnownTruthFixture::build(); + let request = request(); + let accepted = accepted(&request); + assert_eq!( + execute_event_intelligence_run( + &request, + &accepted, + "snapshot-event-intelligence", + cutoff(), + fixture.input(), + "invalid", + ), + Err(AnalysisEngineError::Api( + tepp_api::ApiError::InvalidWirePayload + )) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..e88584f7d 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 | +| TDT/CHRONOS workflow as an analysis-run profile | ADR 0016/0030; Allan (2002); Li et al. (2021); Anagnostopoulos et al. (2013) | `analysis_engine` `tdt_chronos_workflow_v1` binds `compose_event_intelligence`, cutoff-filters mentions, digest-binds `tepp.tdt_chronos_workflow.v1`, and refuses instance/transition promotion; not a new extractor and not persistence | 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/0030-event-intelligence-analysis-run.md b/docs/adr/0030-event-intelligence-analysis-run.md new file mode 100644 index 000000000..19c3f11bd --- /dev/null +++ b/docs/adr/0030-event-intelligence-analysis-run.md @@ -0,0 +1,98 @@ +# ADR 0030 — TDT/CHRONOS composition as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on the active product branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0016 event-intelligence layers and ADR 0022 analysis-run execution. +**Figma File ID:** N/A — this increment changes a Rust analysis crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already admits already-extracted TDT detection artifacts and +CHRONOS schema/forecast hypotheses into one versioned workflow +(`compose_event_intelligence` in `event_core`, ADR 0016). Operators still cannot +request that composed workflow as an analysis-run output profile. Without an +engine bind, the composition remains a library call: it is not cutoff-filtered +against the accepted run, not digest-bound as a terminal result, and not +explicitly refused as an event instance or state transition at the analysis-run +boundary. + +This slice does not invent a new extractor, persist the composition, or promote +detection/prediction into a forward transition. + +## Decision + +Add the `tdt_chronos_workflow_v1` analysis-run output profile to +`analysis_engine`. For that profile the engine: + +- requires `model_contract_version` `event_intelligence_workflow_v1` and the + request snapshot/cutoff to match the execution arguments; +- consumes already-extracted mentions, links, first-story labels, tracks, + schema slots, and forecasts through `EventIntelligenceRunInput`; +- excludes mentions whose `available_time` is later than the request + `knowledge_cutoff`, drops links that cite an excluded mention, and keeps + first-story/track streams index-aligned to remaining mentions; +- calls `compose_event_intelligence` and the explicit + `refuse_composition_as_instance` / `refuse_composition_as_transition` + refusals; +- emits a canonical SHA-256-digested `tepp.tdt_chronos_workflow.v1` artifact + with bounded counts, TDT envelope layer `tdt_detection`, CHRONOS hypothesis + layer `chronos_prediction`, and inference status + `composed_workflow_not_instance_or_transition`; +- fails closed when no mention remains eligible, when first-story or track + streams are not length-aligned, when snapshot/profile/cutoff diverge, or when + the artifact claim boundary is tampered. + +The engine does not extract mentions from source text, does not write +persistence rows, and does not create an event instance or state transition. + +## Alternatives considered + +1. Treat library composition on main as product-complete — rejected because + operators still cannot request the workflow as an analysis run. +2. Invent a new extractor inside `analysis_engine` — rejected because ADR 0016 + already owns extraction/admission; this slice only binds admitted artifacts. +3. Persist the composition or promote it to an instance/transition — rejected + because persistence is GAP-003B and promotion remains an independent + authority. +4. Bind the existing composition through the analysis-run profile — accepted. + +## Consequences + +Consumers can request a reproducible TDT/CHRONOS workflow and receive a +digest-bound terminal result that preserves detection-versus-prediction layers. +Source text never enters the artifact. Later HTTP, persistence, and export +slices must consume this schema rather than re-compose a second authority. + +## Verification + +The stacked PR includes Rust unit and integration tests for canonical artifact +round-trip, tamper refusal, known-truth composition counts, cutoff exclusion of +a delayed revised mention, snapshot/profile/cutoff mismatch, stream +misalignment, empty eligibility, and receipt identity. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +The supporting research citations remain Allan (2002), Li et al. (2021), and +Anagnostopoulos, Batsakis, and Petrakis (2013) as recorded under ADR 0016. + +## Rollback and supersession + +Rollback removes the `tdt_chronos_workflow_v1` profile and the +`event_intelligence_artifact` module while preserving `event_core` composition +and the readiness/topic-lineage executors. No persisted schema migration is +introduced. Supersession requires a new ADR if the profile changes cutoff +semantics, claim-boundary copy, or promotion authority. + +## References + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. + +Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 + +Li, M., Li, S., Wang, Z., Huang, L., Cho, K., Ji, H., Han, J., & Voss, C. (2021). The future is not one-dimensional: Complex event schema induction by graph modeling for event prediction. In *Proceedings of the 2021 Conference on Empirical Methods in Natural Language Processing* (pp. 5203–5215). Association for Computational Linguistics. https://doi.org/10.18653/v1/2021.emnlp-main.422 diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..8f210e498 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. | +| [0030](0030-event-intelligence-analysis-run.md) | TDT/CHRONOS composition as an analysis-run output profile | Accepted | active-PR | Binds `compose_event_intelligence` to `tdt_chronos_workflow_v1`; cutoff-filters mentions; refuses instance/transition 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. | @@ -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. +- **TDT/CHRONOS analysis-run output profile:** ADR 0030. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/event-intelligence-analysis-run.md b/docs/doctoring/event-intelligence-analysis-run.md new file mode 100644 index 000000000..5c47a40e6 --- /dev/null +++ b/docs/doctoring/event-intelligence-analysis-run.md @@ -0,0 +1,36 @@ +# TDT/CHRONOS analysis-run bind — GAP-007 operator slice + +**Review date:** 2026-08-31 +**Active slice:** `analysis_engine` output profile `tdt_chronos_workflow_v1` +for issue #170 / GAP-007 + +Protected main already composes admitted TDT detection artifacts and CHRONOS +schema/forecast hypotheses in `event_core` (`compose_event_intelligence`). +Operators still could not request that workflow as a cutoff-safe analysis run +with a digest-bound terminal result. + +## Bounded closure + +`execute_event_intelligence_run` binds the existing composition to one +analysis-run profile. It: + +- excludes mentions unavailable at the request knowledge cutoff; +- drops TDT links that cite an excluded mention; +- keeps first-story and track streams aligned to remaining mentions; +- records envelope layer `tdt_detection` and hypothesis layer + `chronos_prediction`; +- records inference status `composed_workflow_not_instance_or_transition`; +- invokes `refuse_composition_as_instance` and + `refuse_composition_as_transition`; +- emits canonical `tepp.tdt_chronos_workflow.v1` JSON and its SHA-256 digest + on the succeeded terminal result. + +This slice does not extract mentions, persist rows, promote an event instance, +or create a state transition. HTTP status/lifecycle and Compose persistence +remain separate live PRs. + +## Evidence boundary + +The current implementation is active-PR evidence only. Exact-head checks, +independent review, and protected merge are required before the capability is +promoted to implemented-main.