diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 8def42a0..72bd5854 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -337,8 +337,7 @@ pub fn execute_analysis_run( // future bound change cannot wrap membership totals silently. let eligible_evidence_count = eligible.len() as u64; let eligible_membership_count = eligible.iter().try_fold(0_u64, |sum, unit| { - sum.checked_add(u64::from(unit.membership_count)) - .ok_or(AnalysisEngineError::ArithmeticOverflow) + add_membership_count(sum, unit.membership_count) })?; let (earliest, latest) = eligible.iter().fold( (eligible[0].event_time, eligible[0].event_time), @@ -379,6 +378,11 @@ pub fn execute_analysis_run( }) } +fn add_membership_count(sum: u64, membership_count: u32) -> Result { + sum.checked_add(u64::from(membership_count)) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + /// Require the accepted receipt to carry the request's idempotency identity. fn require_receipt_identity( request: &AnalysisRunRequest, @@ -409,7 +413,7 @@ mod tests { use super::{ ANALYSIS_ARTIFACT_SCHEMA_VERSION, ANALYSIS_STATISTIC_COUNT, AnalysisCorpus, AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, - MAX_EVIDENCE_UNITS, TopicMeasurementError, execute_analysis_run, + MAX_EVIDENCE_UNITS, TopicMeasurementError, add_membership_count, execute_analysis_run, }; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -683,6 +687,13 @@ mod tests { } let converted: AnalysisEngineError = ApiError::InvalidWirePayload.into(); 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"); + assert_eq!( + add_membership_count(u64::MAX, 1), + Err(AnalysisEngineError::ArithmeticOverflow) + ); + assert_eq!(add_membership_count(0, 4), Ok(4)); } #[test] diff --git a/crates/event_core/src/composition.rs b/crates/event_core/src/composition.rs new file mode 100644 index 00000000..4ec7af5e --- /dev/null +++ b/crates/event_core/src/composition.rs @@ -0,0 +1,622 @@ +//! Versioned TDT/CHRONOS workflow composition over admitted artifacts. +//! +//! Allan (2002) defines Topic Detection and Tracking as linked detection +//! tasks—segmentation, link detection, first-story detection, and tracking— +//! rather than a single opaque model. Li et al. (2021) treat schema and next- +//! event forecasts as graph hypotheses. Anagnostopoulos, Batsakis, and +//! Petrakis (2013) keep CHRONOS-style reasoning distinct from observed fact. +//! This module admits already-extracted artifacts into one versioned workflow +//! and never invents a new extractor or a silent promotion path. + +use crate::{ + ChronosOccurrenceForecast, EventConfidence, EventError, EventEvidenceLayer, EventInstanceId, + EventLinkPair, EventMention, EventTrackAssignment, FirstStoryLabel, SchemaSlotAssignment, + StorySegmentation, +}; + +/// Wire schema version for the unified event-intelligence workflow. +pub const EVENT_INTELLIGENCE_WORKFLOW_VERSION: u16 = 1; + +/// Named thresholds and version for one reproducible TDT/CHRONOS run. +/// +/// Callers pass these thresholds into the existing `decide_*` helpers for +/// link, first-story, track, schema-slot, boundary, and occurrence forecasts. +/// An empty (`0`) or unsupported version fails closed. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EventIntelligenceWorkflowConfig { + version: u16, + link_threshold: EventConfidence, + first_story_threshold: EventConfidence, + track_threshold: EventConfidence, + schema_threshold: EventConfidence, + boundary_threshold: EventConfidence, + forecast_threshold: EventConfidence, +} + +impl EventIntelligenceWorkflowConfig { + /// Validate a workflow version and named decision thresholds. + /// + /// # Errors + /// + /// Returns [`EventError::InvalidWirePayload`] when `version` is `0` + /// (empty). Returns [`EventError::UnsupportedWireVersion`] when `version` + /// is not [`EVENT_INTELLIGENCE_WORKFLOW_VERSION`]. + pub fn new( + version: u16, + link_threshold: EventConfidence, + first_story_threshold: EventConfidence, + track_threshold: EventConfidence, + schema_threshold: EventConfidence, + boundary_threshold: EventConfidence, + forecast_threshold: EventConfidence, + ) -> Result { + if version == 0 { + return Err(EventError::InvalidWirePayload); + } + if version != EVENT_INTELLIGENCE_WORKFLOW_VERSION { + return Err(EventError::UnsupportedWireVersion); + } + Ok(Self { + version, + link_threshold, + first_story_threshold, + track_threshold, + schema_threshold, + boundary_threshold, + forecast_threshold, + }) + } + + /// Return the validated workflow version. + #[must_use] + pub const fn version(self) -> u16 { + self.version + } + + /// Return the link-decision threshold for [`crate::decide_event_link`]. + #[must_use] + pub const fn link_threshold(self) -> EventConfidence { + self.link_threshold + } + + /// Return the first-story threshold for [`crate::decide_first_story`]. + #[must_use] + pub const fn first_story_threshold(self) -> EventConfidence { + self.first_story_threshold + } + + /// Return the track-continue threshold for [`crate::decide_track_continue`]. + #[must_use] + pub const fn track_threshold(self) -> EventConfidence { + self.track_threshold + } + + /// Return the schema-slot threshold for [`crate::decide_schema_slot`]. + #[must_use] + pub const fn schema_threshold(self) -> EventConfidence { + self.schema_threshold + } + + /// Return the story-boundary threshold for [`crate::decide_story_boundary`]. + #[must_use] + pub const fn boundary_threshold(self) -> EventConfidence { + self.boundary_threshold + } + + /// Return the occurrence-forecast threshold used with forecast probabilities. + #[must_use] + pub const fn forecast_threshold(self) -> EventConfidence { + self.forecast_threshold + } +} + +/// Ordered TDT/CHRONOS artifacts admitted under one workflow version. +/// +/// TDT segmentation, links, first-story labels, and tracks remain +/// [`EventEvidenceLayer::TdtDetection`]. Schema-slot assignments and +/// occurrence forecasts remain [`EventEvidenceLayer::ChronosPrediction`]. +/// The composition itself is never [`EventEvidenceLayer::PromotedTransition`]. +#[derive(Clone, Debug, PartialEq)] +pub struct EventIntelligenceComposition { + config: EventIntelligenceWorkflowConfig, + envelope_layer: EventEvidenceLayer, + hypothesis_layer: EventEvidenceLayer, + mentions: Vec, + segmentation: StorySegmentation, + links: Vec, + first_story_labels: Vec, + track_assignments: Vec, + schema_slot_assignments: Vec, + occurrence_forecasts: Vec, +} + +impl EventIntelligenceComposition { + /// Return the workflow configuration version stored with this composition. + #[must_use] + pub const fn config_version(&self) -> u16 { + self.config.version() + } + + /// Return the validated workflow configuration. + #[must_use] + pub const fn config(&self) -> EventIntelligenceWorkflowConfig { + self.config + } + + /// Return the ordered span-grounded mentions. + #[must_use] + pub fn mentions(&self) -> &[EventMention] { + &self.mentions + } + + /// Return the admitted story/event segmentation. + #[must_use] + pub const fn segmentation(&self) -> &StorySegmentation { + &self.segmentation + } + + /// Return the admitted TDT link pairs. + #[must_use] + pub fn links(&self) -> &[EventLinkPair] { + &self.links + } + + /// Return the admitted first-story labels aligned to mentions. + #[must_use] + pub fn first_story_labels(&self) -> &[FirstStoryLabel] { + &self.first_story_labels + } + + /// Return the admitted track assignments aligned to mentions. + #[must_use] + pub fn track_assignments(&self) -> &[EventTrackAssignment] { + &self.track_assignments + } + + /// Return the admitted CHRONOS schema-slot fills. + #[must_use] + pub fn schema_slot_assignments(&self) -> &[SchemaSlotAssignment] { + &self.schema_slot_assignments + } + + /// Return the admitted CHRONOS occurrence forecasts. + #[must_use] + pub fn occurrence_forecasts(&self) -> &[ChronosOccurrenceForecast] { + &self.occurrence_forecasts + } + + /// Epistemic layer of the composed workflow envelope. + /// + /// TDT artifacts remain [`EventEvidenceLayer::TdtDetection`]. Schema slots + /// and occurrence forecasts remain [`EventEvidenceLayer::ChronosPrediction`] + /// hypotheses. The composition itself is never a promoted transition. + #[must_use] + pub const fn evidence_layer(&self) -> EventEvidenceLayer { + self.envelope_layer + } + + /// Epistemic layer retained by composed CHRONOS schema/forecast artifacts. + #[must_use] + pub const fn chronos_evidence_layer(&self) -> EventEvidenceLayer { + self.hypothesis_layer + } + + /// Append a later-arriving revised-document mention without rewriting earlier + /// mention identities, spans, or track assignments. + /// + /// # Errors + /// + /// Returns [`EventError::InvalidWirePayload`] when the track assignment does + /// not cite the appended mention identity, or when that identity is already + /// admitted. + pub fn append_revised_mention( + &mut self, + mention: EventMention, + first_story_label: FirstStoryLabel, + track_assignment: EventTrackAssignment, + ) -> Result<(), EventError> { + if track_assignment.mention_id() != mention.mention_id() { + return Err(EventError::InvalidWirePayload); + } + if self + .mentions + .iter() + .any(|existing| existing.mention_id() == mention.mention_id()) + { + return Err(EventError::InvalidWirePayload); + } + self.mentions.push(mention); + self.first_story_labels.push(first_story_label); + self.track_assignments.push(track_assignment); + Ok(()) + } +} + +/// Admit already-extracted TDT/CHRONOS artifacts into one versioned workflow. +/// +/// Sequence retained for audit: segmentation → span-grounded mentions → links → +/// first-story → tracks → schema slots → forecasts. This function does not +/// invent a new extractor; callers supply validated artifacts. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when mentions are empty, when +/// mention identities are not unique, when first-story or track streams are +/// not length-aligned to mentions, or when a track assignment is not +/// index-aligned to the matching mention identity. +/// Propagates config version errors from +/// [`EventIntelligenceWorkflowConfig::new`] when the supplied config is reused +/// only after validation (callers must construct config first). +#[allow(clippy::too_many_arguments, reason = "audited TDT/CHRONOS sequence")] +pub fn compose_event_intelligence( + config: EventIntelligenceWorkflowConfig, + segmentation: StorySegmentation, + mentions: Vec, + links: Vec, + first_story_labels: Vec, + track_assignments: Vec, + schema_slot_assignments: Vec, + occurrence_forecasts: Vec, +) -> Result { + if mentions.is_empty() { + return Err(EventError::InvalidWirePayload); + } + if first_story_labels.len() != mentions.len() { + return Err(EventError::InvalidWirePayload); + } + if track_assignments.len() != mentions.len() { + return Err(EventError::InvalidWirePayload); + } + let mention_ids: Vec<_> = mentions.iter().map(EventMention::mention_id).collect(); + for (index, mention_id) in mention_ids.iter().enumerate() { + if mention_ids[..index].iter().any(|prior| prior == mention_id) { + return Err(EventError::InvalidWirePayload); + } + } + for (mention, assignment) in mentions.iter().zip(&track_assignments) { + if mention.mention_id() != assignment.mention_id() { + return Err(EventError::InvalidWirePayload); + } + } + for link in &links { + let left_known = mention_ids.iter().any(|id| *id == link.left()); + let right_known = mention_ids.iter().any(|id| *id == link.right()); + if !left_known { + return Err(EventError::InvalidWirePayload); + } + if !right_known { + return Err(EventError::InvalidWirePayload); + } + } + Ok(EventIntelligenceComposition { + config, + envelope_layer: EventEvidenceLayer::TdtDetection, + hypothesis_layer: EventEvidenceLayer::ChronosPrediction, + mentions, + segmentation, + links, + first_story_labels, + track_assignments, + schema_slot_assignments, + occurrence_forecasts, + }) +} + +/// Explicit refusal to treat a composed workflow as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::IntelligenceWorkflowIsNotEventInstance`]. +pub fn refuse_composition_as_instance( + _composition: &EventIntelligenceComposition, +) -> Result { + Err(EventError::IntelligenceWorkflowIsNotEventInstance) +} + +/// Explicit refusal to treat a composed workflow as a state transition. +/// +/// # Errors +/// +/// Always returns [`EventError::IntelligenceWorkflowIsNotStateTransition`]. +pub fn refuse_composition_as_transition( + _composition: &EventIntelligenceComposition, +) -> Result<(), EventError> { + Err(EventError::IntelligenceWorkflowIsNotStateTransition) +} + +#[cfg(test)] +mod tests { + use super::{ + EVENT_INTELLIGENCE_WORKFLOW_VERSION, EventIntelligenceWorkflowConfig, + compose_event_intelligence, refuse_composition_as_instance, + refuse_composition_as_transition, + }; + use crate::{ + EventConfidence, EventError, EventEvidenceLayer, EventLinkPair, EventMention, + EventTrackAssignment, EventTrackId, FirstStoryLabel, MentionEvidenceClocks, + MentionReviewStatus, StorySegmentation, + }; + use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; + use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, + }; + + 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() -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T12:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("clocks") + } + + fn grounded(document: &DocumentRecord, surface: &str) -> EventMention { + EventMention::new( + document, + span_for(document, surface), + EventConfidence::new(0.9).expect("confidence"), + clocks(), + "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") + } + + #[test] + fn workflow_config_rejects_empty_and_unsupported_versions() { + assert_eq!( + EventIntelligenceWorkflowConfig::new(0, half(), half(), half(), half(), half(), half()), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + EventIntelligenceWorkflowConfig::new( + 99, + half(), + half(), + half(), + half(), + half(), + half() + ), + Err(EventError::UnsupportedWireVersion) + ); + let config = workflow_config(); + assert_eq!(config.version(), EVENT_INTELLIGENCE_WORKFLOW_VERSION); + assert_eq!(config.link_threshold(), half()); + assert_eq!(config.first_story_threshold(), half()); + assert_eq!(config.track_threshold(), half()); + assert_eq!(config.schema_threshold(), half()); + assert_eq!(config.boundary_threshold(), half()); + assert_eq!(config.forecast_threshold(), half()); + } + + #[test] + fn compose_refuses_empty_mentions_and_stream_misalignment() { + let original = record("award protest later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + vec![FirstStoryLabel::FirstStory], + tracks.clone(), + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + Vec::new(), + labels, + vec![tracks[0]], + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + } + + #[test] + fn compose_refuses_unknown_tracks_and_foreign_links() { + let original = record("award protest later"); + let revised = record("revised award later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let later = grounded(&revised, "award"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let stranger = EventTrackAssignment::new(later.mention_id(), EventTrackId::from_raw(9)); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + labels.clone(), + vec![tracks[0], stranger], + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let foreign_right = + EventLinkPair::new(award.mention_id(), later.mention_id()).expect("foreign right"); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + vec![foreign_right], + labels.clone(), + tracks.clone(), + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let foreign_left = + EventLinkPair::new(later.mention_id(), award.mention_id()).expect("foreign left"); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + vec![foreign_left], + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + } + + #[test] + fn compose_exposes_layers_and_refuses_mismatched_append() { + let original = record("award protest later"); + let revised = record("revised award later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let later = grounded(&revised, "award"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let mut composition = compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + Vec::new(), + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .expect("compose"); + assert_eq!( + composition.config_version(), + EVENT_INTELLIGENCE_WORKFLOW_VERSION + ); + assert_eq!(composition.config(), workflow_config()); + assert_eq!( + composition.evidence_layer(), + EventEvidenceLayer::TdtDetection + ); + assert_eq!( + composition.chronos_evidence_layer(), + EventEvidenceLayer::ChronosPrediction + ); + assert_eq!(composition.mentions().len(), 2); + assert!(composition.links().is_empty()); + assert_eq!(composition.first_story_labels().len(), 2); + assert_eq!(composition.track_assignments().len(), 2); + assert!(composition.schema_slot_assignments().is_empty()); + assert!(composition.occurrence_forecasts().is_empty()); + let mismatched = EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)); + assert_eq!( + composition.append_revised_mention( + later.clone(), + FirstStoryLabel::FollowUp, + mismatched + ), + Err(EventError::InvalidWirePayload) + ); + composition + .append_revised_mention( + later.clone(), + FirstStoryLabel::FollowUp, + EventTrackAssignment::new(later.mention_id(), EventTrackId::from_raw(1)), + ) + .expect("matching append"); + assert_eq!( + refuse_composition_as_instance(&composition), + Err(EventError::IntelligenceWorkflowIsNotEventInstance) + ); + assert_eq!( + refuse_composition_as_transition(&composition), + Err(EventError::IntelligenceWorkflowIsNotStateTransition) + ); + } +} diff --git a/crates/event_core/src/criterion_posterior.rs b/crates/event_core/src/criterion_posterior.rs index 3c2d1e31..a3395a20 100644 --- a/crates/event_core/src/criterion_posterior.rs +++ b/crates/event_core/src/criterion_posterior.rs @@ -89,6 +89,28 @@ pub fn fit_independent_criterion_posterior( }) } +fn finite_or_numerical_failure(value: f64) -> Result { + if value.is_finite() { + Ok(value) + } else { + Err(CriterionPosteriorError::NumericalFailure) + } +} + +#[cfg(test)] +fn unit_interval_or_numerical_failure(value: f64) -> Result { + if value.is_finite() && (0.0..=1.0).contains(&value) { + Ok(value) + } else { + Err(CriterionPosteriorError::NumericalFailure) + } +} + +fn lift_continued_fraction_term(value: f64) -> f64 { + const TINY: f64 = 1.0e-300; + if value.abs() < TINY { TINY } else { value } +} + fn beta_quantile(probability: f64, alpha: f64, beta: f64) -> Result { let mut lower = 0.0_f64; let mut upper = 1.0_f64; @@ -115,6 +137,11 @@ fn regularized_beta(x: f64, alpha: f64, beta: f64) -> Result= 1.0 { return Ok(1.0); } + // NIST DLMF 8.17.4: I_x(1,1) = x (uniform CDF). Use the closed form so the + // continued-fraction path cannot lose the exact identity in last-bit noise. + if alpha.to_bits() == 1.0_f64.to_bits() && beta.to_bits() == 1.0_f64.to_bits() { + return finite_or_numerical_failure(x); + } let log_scale = log_gamma(alpha + beta) - log_gamma(alpha) - log_gamma(beta) + alpha * x.ln() + beta * (-x).ln_1p(); @@ -127,24 +154,17 @@ fn regularized_beta(x: f64, alpha: f64, beta: f64) -> Result Result { - const TINY: f64 = 1.0e-300; const EPSILON: f64 = 8.0 * f64::EPSILON; let qab = alpha + beta; let qap = alpha + 1.0; let qam = alpha - 1.0; let mut c = 1.0; let mut d = 1.0 - qab * x / qap; - if d.abs() < TINY { - d = TINY; - } + d = lift_continued_fraction_term(d); d = 1.0 / d; let mut result = d; for iteration in 1..=512 { @@ -152,24 +172,16 @@ fn beta_fraction(x: f64, alpha: f64, beta: f64) -> Result f64 { #[cfg(test)] mod tests { use super::{ - CriterionPosteriorError, beta_fraction, beta_quantile, log_gamma, regularized_beta, + CriterionPosteriorError, IndependentCriterionCounts, beta_fraction, beta_quantile, + finite_or_numerical_failure, fit_independent_criterion_posterior, + lift_continued_fraction_term, log_gamma, regularized_beta, + unit_interval_or_numerical_failure, }; #[test] @@ -236,6 +251,104 @@ mod tests { Err(CriterionPosteriorError::NumericalFailure) ); assert!(log_gamma(0.25).is_finite()); + assert_eq!( + regularized_beta(0.5, 1.0e308, 1.0e308), + Err(CriterionPosteriorError::NumericalFailure) + ); + let _ = beta_fraction(1.0, 1.0, 1.0); + assert_eq!(unit_interval_or_numerical_failure(0.25), Ok(0.25)); + assert_eq!( + unit_interval_or_numerical_failure(f64::INFINITY), + Err(CriterionPosteriorError::NumericalFailure) + ); + assert_eq!( + unit_interval_or_numerical_failure(-0.25), + Err(CriterionPosteriorError::NumericalFailure) + ); + assert_eq!(finite_or_numerical_failure(1.5), Ok(1.5)); + assert_eq!( + finite_or_numerical_failure(f64::NAN), + Err(CriterionPosteriorError::NumericalFailure) + ); + assert_eq!( + lift_continued_fraction_term(0.0).to_bits(), + 1.0e-300f64.to_bits() + ); + assert_eq!( + lift_continued_fraction_term(2.0).to_bits(), + 2.0f64.to_bits() + ); + assert_eq!( + lift_continued_fraction_term(-2.0).to_bits(), + (-2.0f64).to_bits() + ); + assert_eq!(regularized_beta(0.75, 1.0, 1.0), Ok(0.75)); + // NIST DLMF 8.17.5: I_x(1,b)=1-(1-x)^b and I_x(a,1)=x^a. These + // take the continued-fraction path (alpha=1,beta!=1 and vice versa). + let ix_one_two = regularized_beta(0.75, 1.0, 2.0).expect("I_x(1,2)"); + let ix_two_one = regularized_beta(0.75, 2.0, 1.0).expect("I_x(2,1)"); + assert!((ix_one_two - 0.9375).abs() < 8.0 * f64::EPSILON); + assert!((ix_two_one - 0.5625).abs() < 8.0 * f64::EPSILON); + } + + #[test] + fn overflow_draw_count_fails_closed_before_allocation() { + assert_eq!( + fit_independent_criterion_posterior( + IndependentCriterionCounts { + successes: 1, + trials: 2, + }, + (u32::MAX as usize).saturating_add(1), + ), + Err(CriterionPosteriorError::NumericalFailure) + ); + assert_eq!( + fit_independent_criterion_posterior( + IndependentCriterionCounts { + successes: 1, + trials: 0, + }, + 8, + ), + Err(CriterionPosteriorError::EmptyObservations) + ); + assert_eq!( + fit_independent_criterion_posterior( + IndependentCriterionCounts { + successes: 3, + trials: 2, + }, + 8, + ), + Err(CriterionPosteriorError::SuccessesExceedTrials) + ); + assert_eq!( + fit_independent_criterion_posterior( + IndependentCriterionCounts { + successes: 1, + trials: 2, + }, + 1, + ), + Err(CriterionPosteriorError::InsufficientDraws) + ); + let posterior = fit_independent_criterion_posterior( + IndependentCriterionCounts { + successes: 3, + trials: 4, + }, + 8, + ) + .expect("identified Jeffreys posterior"); + assert!((posterior.mean - (3.5 / 5.0)).abs() < 1e-12); + assert_eq!(posterior.plausible_values.len(), 8); + assert!( + posterior + .plausible_values + .windows(2) + .all(|pair| pair[0] <= pair[1]) + ); } #[test] diff --git a/crates/event_core/src/error.rs b/crates/event_core/src/error.rs index 37ba2bab..db1a60cc 100644 --- a/crates/event_core/src/error.rs +++ b/crates/event_core/src/error.rs @@ -66,6 +66,10 @@ pub enum EventError { MentionSpanDocumentMismatch, /// An unknown mention-review status name was supplied. UnknownMentionReviewStatus, + /// A TDT/CHRONOS composition was treated as an event instance. + IntelligenceWorkflowIsNotEventInstance, + /// A TDT/CHRONOS composition was treated as a state transition. + IntelligenceWorkflowIsNotStateTransition, } impl fmt::Display for EventError { @@ -109,6 +113,12 @@ impl fmt::Display for EventError { Self::EmptyExtractorVersion => "empty extractor version", Self::MentionSpanDocumentMismatch => "mention span does not belong to the document", Self::UnknownMentionReviewStatus => "unknown mention review status", + Self::IntelligenceWorkflowIsNotEventInstance => { + "intelligence workflow is not an event instance" + } + Self::IntelligenceWorkflowIsNotStateTransition => { + "intelligence workflow is not a state transition" + } }; formatter.write_str(message) } @@ -232,6 +242,14 @@ mod tests { EventError::UnknownMentionReviewStatus, "unknown mention review status", ), + ( + EventError::IntelligenceWorkflowIsNotEventInstance, + "intelligence workflow is not an event instance", + ), + ( + EventError::IntelligenceWorkflowIsNotStateTransition, + "intelligence workflow is not a state transition", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/event_core/src/event_time_posterior.rs b/crates/event_core/src/event_time_posterior.rs index b5c80bbc..6b9434d4 100644 --- a/crates/event_core/src/event_time_posterior.rs +++ b/crates/event_core/src/event_time_posterior.rs @@ -63,17 +63,37 @@ pub fn materialize_event_time_posterior( return Err(EventTimePosteriorError::DuplicateEventTime); } let draw_count = ordered.iter().try_fold(0_usize, |total, atom| { - let multiplicity = usize::try_from(atom.multiplicity) - .map_err(|_| EventTimePosteriorError::DrawCountOverflow)?; - total - .checked_add(multiplicity) - .ok_or(EventTimePosteriorError::DrawCountOverflow) + add_atom_mass(total, atom.multiplicity) })?; let mut draws = Vec::with_capacity(draw_count); for atom in ordered { - let multiplicity = usize::try_from(atom.multiplicity) - .map_err(|_| EventTimePosteriorError::DrawCountOverflow)?; - draws.extend(std::iter::repeat_n(atom.event_time, multiplicity)); + draws.extend(std::iter::repeat_n( + atom.event_time, + usize::try_from(atom.multiplicity) + .map_err(|_| EventTimePosteriorError::DrawCountOverflow)?, + )); } Ok(EventTimePosteriorDraws { draws }) } + +fn add_atom_mass(total: usize, multiplicity: u32) -> Result { + let mass = + usize::try_from(multiplicity).map_err(|_| EventTimePosteriorError::DrawCountOverflow)?; + total + .checked_add(mass) + .ok_or(EventTimePosteriorError::DrawCountOverflow) +} + +#[cfg(test)] +mod tests { + use super::{EventTimePosteriorError, add_atom_mass}; + + #[test] + fn overflowing_draw_count_fails_closed() { + assert_eq!( + add_atom_mass(usize::MAX, 1), + Err(EventTimePosteriorError::DrawCountOverflow) + ); + assert_eq!(add_atom_mass(0, 3), Ok(3)); + } +} diff --git a/crates/event_core/src/lib.rs b/crates/event_core/src/lib.rs index 5e31e37a..0faab5d7 100644 --- a/crates/event_core/src/lib.rs +++ b/crates/event_core/src/lib.rs @@ -9,11 +9,13 @@ //! six-clock evidence, extractor version, and review status, and the surface //! form is the document substring at that span. Mentions, first-story //! detections, TDT detections, and CHRONOS predictions never silently become -//! instances. Track assignments, story segmentations, CHRONOS schema-slot -//! predictions, and occurrence forecasts remain measurement or hypothesis -//! artifacts and cannot promote an instance without an explicit -//! evidence-backed promotion gate. +//! instances. [`EventIntelligenceComposition`] is the versioned TDT/CHRONOS +//! workflow over admitted artifacts; promotion still requires the existing +//! evidence-backed gate. Track assignments, story segmentations, CHRONOS +//! schema-slot predictions, and occurrence forecasts remain measurement or +//! hypothesis artifacts and cannot promote an instance without that gate. +mod composition; mod confidence; mod criterion_posterior; mod error; @@ -33,6 +35,18 @@ mod span_mention; mod temporal_relation_posterior; mod track; +/// Wire schema version for the unified event-intelligence workflow. +pub use composition::EVENT_INTELLIGENCE_WORKFLOW_VERSION; +/// Versioned TDT/CHRONOS composition over admitted artifacts. +pub use composition::EventIntelligenceComposition; +/// Named thresholds and version for one reproducible intelligence run. +pub use composition::EventIntelligenceWorkflowConfig; +/// Admit already-extracted TDT/CHRONOS artifacts into one versioned workflow. +pub use composition::compose_event_intelligence; +/// Explicit refusal to treat a composition as an event instance. +pub use composition::refuse_composition_as_instance; +/// Explicit refusal to treat a composition as a state transition. +pub use composition::refuse_composition_as_transition; /// Finite confidence on the closed unit interval. pub use confidence::EventConfidence; /// Mean squared error of mention probabilities against binary truth. diff --git a/crates/event_core/src/span_mention.rs b/crates/event_core/src/span_mention.rs index dce17b38..5626f868 100644 --- a/crates/event_core/src/span_mention.rs +++ b/crates/event_core/src/span_mention.rs @@ -295,6 +295,10 @@ mod tests { ); assert!((mention_span_recall(&[award], &[award]).expect("r") - 1.0).abs() < f64::EPSILON); assert_eq!(unique_extent_set(&[]), Err(EventError::InvalidWirePayload)); + assert_eq!( + unique_extent_set(&[award, award]), + Err(EventError::InvalidWirePayload) + ); assert_eq!(counted_rate(0, 0), Err(EventError::InvalidWirePayload)); assert_eq!( counted_rate(usize::MAX, 1), diff --git a/crates/event_core/tests/tdt_chronos_composition_contract.rs b/crates/event_core/tests/tdt_chronos_composition_contract.rs new file mode 100644 index 00000000..1247e5c8 --- /dev/null +++ b/crates/event_core/tests/tdt_chronos_composition_contract.rs @@ -0,0 +1,336 @@ +//! Versioned TDT/CHRONOS composition recovers known-truth metrics and refuses promotion. +//! +//! Fixture mirrors Allan (2002) noisy duplicate stories plus a delayed revised +//! document, Li et al. (2021) schema/forecast hypotheses, and Anagnostopoulos, +//! Batsakis, and Petrakis (2013) separation of prediction from observed fact. + +use event_core::{ + ChronosOccurrenceForecast, ChronosPredictionId, EVENT_INTELLIGENCE_WORKFLOW_VERSION, + EventConfidence, EventError, EventEvidenceLayer, EventIntelligenceComposition, + EventIntelligenceWorkflowConfig, EventLinkPair, EventMention, EventRoleKind, + EventTrackAssignment, EventTrackId, FirstStoryLabel, MentionEvidenceClocks, + MentionReviewStatus, OccurrenceTruth, SchemaSlotAssignment, StorySegmentation, + admit_state_transition, chronos_prediction_brier_score, compose_event_intelligence, + event_link_precision, event_link_recall, first_story_false_alarm_rate, first_story_miss_rate, + mention_span_precision, mention_span_recall, refuse_composition_as_instance, + refuse_composition_as_transition, schema_slot_precision, schema_slot_recall, story_pk, + story_window_diff, tracking_pair_precision, tracking_pair_recall, +}; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +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 { + original: DocumentRecord, + noisy: DocumentRecord, + revised: DocumentRecord, + 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 { + original, + noisy, + revised, + award_original, + protest_original, + award_noisy, + award_revised, + } + } +} + +impl KnownTruthFixture { + 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 compose(&self) -> EventIntelligenceComposition { + compose_event_intelligence( + workflow_config(), + StorySegmentation::new(3, vec![false, true]).expect("recovered segmentation"), + self.mentions(), + self.links(), + Self::first_story_labels(), + self.track_assignments(), + Self::schema_slots(), + Self::forecasts(), + ) + .expect("compose workflow") + } +} + +#[test] +fn composition_recovers_known_truth_span_and_segmentation_metrics() { + let fixture = KnownTruthFixture::build(); + let truth_spans = [ + span_for(&fixture.original, "awarded the river-crossing contract"), + span_for(&fixture.original, "protest"), + span_for(&fixture.noisy, "awarded river-crossing contract"), + ]; + let recovered_spans = [ + fixture.award_original.source_span(), + fixture.protest_original.source_span(), + fixture.award_noisy.source_span(), + ]; + let mention_precision = + mention_span_precision(&truth_spans, &recovered_spans).expect("mention p"); + let mention_recall = mention_span_recall(&truth_spans, &recovered_spans).expect("mention r"); + assert!((mention_precision - 1.0).abs() < f64::EPSILON); + assert!((mention_recall - 1.0).abs() < f64::EPSILON); + let truth_segmentation = + StorySegmentation::new(3, vec![false, true]).expect("truth segmentation"); + let composition = fixture.compose(); + let window_diff = + story_window_diff(&truth_segmentation, composition.segmentation(), 1).expect("wd"); + let pk = story_pk(&truth_segmentation, composition.segmentation(), 1).expect("pk"); + assert!(window_diff.abs() < f64::EPSILON); + assert!(pk.abs() < f64::EPSILON); +} + +#[test] +fn composition_recovers_link_track_first_story_schema_and_brier() { + let fixture = KnownTruthFixture::build(); + let links = fixture.links(); + let first_story_labels = KnownTruthFixture::first_story_labels(); + let track_assignments = fixture.track_assignments(); + let schema_slots = KnownTruthFixture::schema_slots(); + let composition = fixture.compose(); + let link_precision = event_link_precision(&links, composition.links()).expect("link p"); + let link_recall = event_link_recall(&links, composition.links()).expect("link r"); + assert!((link_precision - 1.0).abs() < f64::EPSILON); + assert!((link_recall - 1.0).abs() < f64::EPSILON); + let track_precision = + tracking_pair_precision(&track_assignments, composition.track_assignments()) + .expect("track p"); + let track_recall = + tracking_pair_recall(&track_assignments, composition.track_assignments()).expect("track r"); + assert!((track_precision - 1.0).abs() < f64::EPSILON); + assert!((track_recall - 1.0).abs() < f64::EPSILON); + let miss = + first_story_miss_rate(&first_story_labels, composition.first_story_labels()).expect("miss"); + let far = first_story_false_alarm_rate(&first_story_labels, composition.first_story_labels()) + .expect("far"); + assert!(miss.abs() < f64::EPSILON); + assert!(far.abs() < f64::EPSILON); + let slot_precision = + schema_slot_precision(&schema_slots, composition.schema_slot_assignments()) + .expect("slot p"); + let slot_recall = + schema_slot_recall(&schema_slots, composition.schema_slot_assignments()).expect("slot r"); + assert!((slot_precision - 1.0).abs() < f64::EPSILON); + assert!((slot_recall - 1.0).abs() < f64::EPSILON); + let outcomes = [OccurrenceTruth::Occurred]; + let brier = chronos_prediction_brier_score(composition.occurrence_forecasts(), &outcomes) + .expect("brier"); + let expected_brier = (0.75_f64 - 1.0).powi(2); + assert!((brier - expected_brier).abs() < 1e-15); +} + +#[test] +fn composition_refuses_promotion_and_preserves_earlier_mention_identity() { + let fixture = KnownTruthFixture::build(); + let mut composition = fixture.compose(); + assert_eq!( + refuse_composition_as_instance(&composition), + Err(EventError::IntelligenceWorkflowIsNotEventInstance) + ); + assert_eq!( + refuse_composition_as_transition(&composition), + Err(EventError::IntelligenceWorkflowIsNotStateTransition) + ); + assert_ne!( + composition.evidence_layer(), + EventEvidenceLayer::PromotedTransition + ); + assert_eq!( + admit_state_transition(composition.evidence_layer()), + Err(EventError::DetectionIsNotTransition) + ); + assert_eq!( + composition.chronos_evidence_layer(), + EventEvidenceLayer::ChronosPrediction + ); + assert_eq!( + admit_state_transition(composition.chronos_evidence_layer()), + Err(EventError::PredictionIsNotFact) + ); + let earlier_mention_id = composition.mentions()[0].mention_id(); + let earlier_span = composition.mentions()[0].source_span(); + let earlier_track = composition.track_assignments()[0].track_id(); + let earlier_surface = composition.mentions()[0].surface_form().to_string(); + composition + .append_revised_mention( + fixture.award_revised.clone(), + FirstStoryLabel::FollowUp, + EventTrackAssignment::new( + fixture.award_revised.mention_id(), + EventTrackId::from_raw(1), + ), + ) + .expect("append revised document mention"); + assert_eq!(composition.mentions()[0].mention_id(), earlier_mention_id); + assert_eq!(composition.mentions()[0].source_span(), earlier_span); + assert_eq!(composition.mentions()[0].surface_form(), earlier_surface); + assert_eq!(composition.track_assignments()[0].track_id(), earlier_track); + assert_eq!( + composition.mentions().last().map(EventMention::mention_id), + Some(fixture.award_revised.mention_id()) + ); + assert_eq!(composition.mentions().len(), 4); + assert_eq!(composition.first_story_labels().len(), 4); + assert_eq!(composition.track_assignments().len(), 4); + let _ = (&fixture.noisy, &fixture.revised); +} diff --git a/crates/event_core/tests/tdt_chronos_composition_fail_closed.rs b/crates/event_core/tests/tdt_chronos_composition_fail_closed.rs new file mode 100644 index 00000000..f6fc5161 --- /dev/null +++ b/crates/event_core/tests/tdt_chronos_composition_fail_closed.rs @@ -0,0 +1,414 @@ +//! Fail-closed TDT/CHRONOS composition paths stay refuse-first. + +use event_core::{ + EVENT_INTELLIGENCE_WORKFLOW_VERSION, EventConfidence, EventError, + EventIntelligenceWorkflowConfig, EventLinkPair, EventMention, EventTrackAssignment, + EventTrackId, FirstStoryLabel, MentionEvidenceClocks, MentionReviewStatus, StorySegmentation, + compose_event_intelligence, decide_event_link, decide_first_story, decide_schema_slot, + decide_story_boundary, decide_track_continue, +}; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +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() -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T12:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T09:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("clocks") +} + +fn grounded(document: &DocumentRecord, surface: &str) -> EventMention { + EventMention::new( + document, + span_for(document, surface), + EventConfidence::new(0.9).expect("confidence"), + clocks(), + "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") +} + +#[test] +fn compose_refuses_unknown_track_mentions_and_foreign_links() { + let original = record("award protest later"); + let revised = record("revised award later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let later = grounded(&revised, "award"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let link = EventLinkPair::new(award.mention_id(), protest.mention_id()).expect("link"); + let stranger = EventTrackAssignment::new(later.mention_id(), EventTrackId::from_raw(9)); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + vec![link], + labels.clone(), + vec![tracks[0], stranger], + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let foreign = EventLinkPair::new(award.mention_id(), later.mention_id()).expect("foreign"); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + vec![foreign], + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn workflow_config_thresholds_drive_existing_decide_helpers() { + let config = workflow_config(); + assert_eq!(config.version(), EVENT_INTELLIGENCE_WORKFLOW_VERSION); + let cut = half(); + let _ = decide_event_link(cut, config.link_threshold()); + let _ = decide_first_story(cut, config.first_story_threshold()); + let _ = decide_track_continue(cut, config.track_threshold()); + let _ = decide_schema_slot(cut, config.schema_threshold()); + let _ = decide_story_boundary(cut, config.boundary_threshold()); + assert!(cut.value() >= config.forecast_threshold().value()); + assert!((config.link_threshold().value() - 0.5).abs() < f64::EPSILON); +} + +#[test] +fn empty_mentions_and_bad_versions_fail_closed_before_composition() { + assert_eq!( + EventIntelligenceWorkflowConfig::new(0, half(), half(), half(), half(), half(), half()), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + EventIntelligenceWorkflowConfig::new(99, half(), half(), half(), half(), half(), half()), + Err(EventError::UnsupportedWireVersion) + ); + let segmentation = StorySegmentation::new(2, vec![true]).expect("seg"); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn compose_refuses_short_first_story_or_track_alignment() { + let original = record("award protest later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let short_labels = vec![FirstStoryLabel::FirstStory]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + short_labels, + tracks.clone(), + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let short_tracks = vec![tracks[0]]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + labels.clone(), + short_tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let duplicate_tracks = vec![tracks[0], tracks[0]]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + labels.clone(), + duplicate_tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let reversed_tracks = vec![tracks[1], tracks[0]]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation.clone(), + mentions.clone(), + Vec::new(), + labels.clone(), + reversed_tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); + let duplicate_mentions = vec![award.clone(), award.clone()]; + let duplicate_mention_tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + ]; + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + duplicate_mentions, + Vec::new(), + labels, + duplicate_mention_tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn append_revised_mention_refuses_mismatched_track_then_accepts_match() { + let original = record("award protest later"); + let revised = record("revised award later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let later = grounded(&revised, "award"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let mut composition = compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + Vec::new(), + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .expect("compose"); + assert_eq!( + composition.append_revised_mention( + award.clone(), + FirstStoryLabel::FollowUp, + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + ), + Err(EventError::InvalidWirePayload) + ); + let mismatched = EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)); + assert_eq!( + composition.append_revised_mention(later.clone(), FirstStoryLabel::FollowUp, mismatched), + Err(EventError::InvalidWirePayload) + ); + composition + .append_revised_mention( + later.clone(), + FirstStoryLabel::FollowUp, + EventTrackAssignment::new(later.mention_id(), EventTrackId::from_raw(1)), + ) + .expect("matching append"); +} + +#[test] +fn compose_refuses_foreign_link_with_unknown_left() { + let original = record("award protest later"); + let revised = record("revised award later"); + let first = grounded(&original, "award"); + let second = grounded(&revised, "award"); + let (unknown, known) = if first.mention_id() < second.mention_id() { + (first, second) + } else { + (second, first) + }; + let segmentation = StorySegmentation::new(2, vec![true]).expect("seg"); + let mentions = vec![known.clone()]; + let labels = vec![FirstStoryLabel::FirstStory]; + let tracks = vec![EventTrackAssignment::new( + known.mention_id(), + EventTrackId::from_raw(1), + )]; + let foreign_left = + EventLinkPair::new(unknown.mention_id(), known.mention_id()).expect("foreign left"); + assert_eq!(foreign_left.left(), unknown.mention_id()); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + vec![foreign_left], + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); +} + +#[test] +fn composition_exposes_stored_config_and_version() { + let original = record("award protest later"); + let award = grounded(&original, "award"); + let protest = grounded(&original, "protest"); + let segmentation = StorySegmentation::new(3, vec![false, true]).expect("seg"); + let mentions = vec![award.clone(), protest.clone()]; + let labels = vec![FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; + let tracks = vec![ + EventTrackAssignment::new(award.mention_id(), EventTrackId::from_raw(1)), + EventTrackAssignment::new(protest.mention_id(), EventTrackId::from_raw(1)), + ]; + let composition = compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + Vec::new(), + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .expect("compose"); + assert_eq!( + composition.config_version(), + EVENT_INTELLIGENCE_WORKFLOW_VERSION + ); + let config = composition.config(); + assert_eq!(config.version(), EVENT_INTELLIGENCE_WORKFLOW_VERSION); + assert_eq!(config.link_threshold(), half()); + assert_eq!(config.first_story_threshold(), half()); + assert_eq!(config.track_threshold(), half()); + assert_eq!(config.schema_threshold(), half()); + assert_eq!(config.boundary_threshold(), half()); + assert_eq!(config.forecast_threshold(), half()); +} + +#[test] +fn compose_refuses_foreign_link_with_unknown_right() { + let original = record("award protest later"); + let revised = record("revised award later"); + let first = grounded(&original, "award"); + let second = grounded(&revised, "award"); + let (known, unknown) = if first.mention_id() < second.mention_id() { + (first, second) + } else { + (second, first) + }; + let segmentation = StorySegmentation::new(2, vec![true]).expect("seg"); + let mentions = vec![known.clone()]; + let labels = vec![FirstStoryLabel::FirstStory]; + let tracks = vec![EventTrackAssignment::new( + known.mention_id(), + EventTrackId::from_raw(1), + )]; + let foreign_right = + EventLinkPair::new(known.mention_id(), unknown.mention_id()).expect("foreign right"); + assert_eq!(foreign_right.right(), unknown.mention_id()); + assert_eq!( + compose_event_intelligence( + workflow_config(), + segmentation, + mentions, + vec![foreign_right], + labels, + tracks, + Vec::new(), + Vec::new(), + ) + .map(|_| ()), + Err(EventError::InvalidWirePayload) + ); +} diff --git a/crates/mlx_native_receipt/src/lib.rs b/crates/mlx_native_receipt/src/lib.rs index 0e9f0599..50369ed1 100644 --- a/crates/mlx_native_receipt/src/lib.rs +++ b/crates/mlx_native_receipt/src/lib.rs @@ -13,7 +13,7 @@ use sha2::{Digest, Sha256}; pub const RECEIPT_SCHEMA_VERSION: &str = "mlx_native_receipt.v1"; /// Canonical JSON payload for one identified MLX execution receipt. -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct ProbeReceipt { /// Wire schema version tag. pub schema_version: &'static str, diff --git a/crates/mlx_native_receipt/src/main.rs b/crates/mlx_native_receipt/src/main.rs index 206ffda3..01d6b7e6 100644 --- a/crates/mlx_native_receipt/src/main.rs +++ b/crates/mlx_native_receipt/src/main.rs @@ -100,13 +100,33 @@ fn run() -> Result> { Err("macOS-native MLX receipt unavailable on this host".into()) } -fn main() -> Result<(), Box> { - let receipt = run()?; - println!("{}", serde_json::to_string(&receipt)?); +fn emit_receipt(receipt: &ProbeReceipt) -> Result<(), Box> { + if !receipt.observed_maximum_difference.is_finite() { + return Err("observed_maximum_difference must be finite".into()); + } + println!("{}", serde_json::to_string(receipt)?); Ok(()) } -#[cfg(all(test, target_os = "macos"))] +/// Emit a receipt from an already-evaluated probe result. +fn execute_probe_from( + result: Result>, +) -> Result<(), Box> { + emit_receipt(&result?) +} + +/// Run the host probe and emit one receipt, or fail closed. +fn execute_probe() -> Result<(), Box> { + execute_probe_from(run()) +} + +#[cfg(not(test))] +fn main() -> Result<(), Box> { + execute_probe() +} + +#[cfg(test)] +#[cfg(target_os = "macos")] mod tests { use super::run; @@ -121,5 +141,72 @@ mod tests { ); assert_eq!(receipt.objective_sha256.len(), 64); assert_eq!(receipt.output_sha256.len(), 64); + super::execute_probe_from(Ok(receipt)).expect("macos receipt must emit"); + super::execute_probe().expect("macos host must emit the probe"); + } +} + +#[cfg(test)] +#[cfg(not(target_os = "macos"))] +mod tests { + use super::{emit_receipt, run}; + use mlx_native_receipt::{ProbeReceipt, RECEIPT_SCHEMA_VERSION}; + + #[test] + fn linux_host_refuses_macos_native_mlx_receipt() { + let error = run().expect_err("linux host must refuse the macOS-native probe"); + assert!( + error + .to_string() + .contains("macOS-native MLX receipt unavailable") + ); + } + + #[test] + fn linux_host_emits_a_constructed_receipt_without_running_mlx() { + let receipt = ProbeReceipt { + schema_version: RECEIPT_SCHEMA_VERSION, + backend_code: "mlx_cpu_macos_native", + execution_environment_code: "macos_native", + objective_sha256: "a".repeat(64), + output_sha256: "b".repeat(64), + observed_maximum_difference: 0.0, + }; + emit_receipt(&receipt).expect("receipt JSON must serialize"); + super::execute_probe_from(Ok(receipt)).expect("finite receipt must emit"); + } + + #[test] + fn linux_host_execute_probe_fails_closed() { + let error = super::execute_probe().expect_err("linux host must refuse the probe"); + assert!( + error + .to_string() + .contains("macOS-native MLX receipt unavailable") + ); + let refused = super::execute_probe_from(Err("probe refused".into())); + assert!(refused.is_err()); + } + + #[test] + fn linux_host_rejects_non_finite_receipt_json() { + let receipt = ProbeReceipt { + schema_version: RECEIPT_SCHEMA_VERSION, + backend_code: "mlx_cpu_macos_native", + execution_environment_code: "macos_native", + objective_sha256: "a".repeat(64), + output_sha256: "b".repeat(64), + observed_maximum_difference: f64::NAN, + }; + emit_receipt(&receipt).expect_err("NaN must fail closed on JSON emit"); + let infinite = ProbeReceipt { + schema_version: RECEIPT_SCHEMA_VERSION, + backend_code: "mlx_cpu_macos_native", + execution_environment_code: "macos_native", + objective_sha256: "a".repeat(64), + output_sha256: "b".repeat(64), + observed_maximum_difference: f64::INFINITY, + }; + emit_receipt(&infinite).expect_err("Infinity must fail closed on JSON emit"); } } diff --git a/crates/mlx_native_receipt/tests/crate_contract.rs b/crates/mlx_native_receipt/tests/crate_contract.rs index faaddd87..e7f3d641 100644 --- a/crates/mlx_native_receipt/tests/crate_contract.rs +++ b/crates/mlx_native_receipt/tests/crate_contract.rs @@ -5,3 +5,32 @@ fn package_identity_is_stable() { let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); assert_eq!(observed, "mlx_native_receipt"); } + +#[test] +fn packaged_binary_obeys_host_mlx_probe_contract() { + let exe = env!("CARGO_BIN_EXE_mlx_native_receipt"); + let output = std::process::Command::new(exe) + .output() + .expect("spawn mlx_native_receipt"); + let stderr = String::from_utf8_lossy(&output.stderr); + #[cfg(not(target_os = "macos"))] + { + assert!( + !output.status.success(), + "linux host must refuse the packaged probe" + ); + assert!( + stderr.contains("macOS-native MLX receipt unavailable"), + "stderr={stderr}" + ); + } + #[cfg(target_os = "macos")] + { + assert!( + output.status.success(), + "macos host must emit the packaged probe stderr={stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("mlx_cpu_macos_native"), "stdout={stdout}"); + } +} diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index addd29b3..4bfc7c3c 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -1,20 +1,8 @@ # ADR 0016 — TDT, CHRONOS, and Event Ontology intelligence boundary **Decision status:** Accepted -**Implementation maturity:** active-PR — `EventMention` is the only constructible mention type and is span-grounded, with exact-extent precision/recall and cutoff-safe six-clock evidence in existing `event_core`; remaining unified TDT/CHRONOS workflow, interval consistency, persistence, and exports remain accepted-target -**Implementation maturity:** active-PR — evidence-layer admission, TDT link precision/recall, and detection-versus-instance refusal live in existing `event_core`, alongside the bounded predicted-vs-observed Allen promotion gate whose coverage authorization precedes any unmatched predicted mass; full TDT tracking/calibration and CHRONOS schema extraction/prediction layers remain accepted-target -**Implementation maturity:** active-PR — evidence-layer admission, first-story false-alarm/miss rates, and detection-versus-instance refusal live in existing `event_core`, alongside the bounded predicted-vs-observed Allen promotion gate whose coverage authorization precedes any unmatched predicted mass; full TDT tracking/calibration and CHRONOS schema extraction/prediction layers remain accepted-target +**Implementation maturity:** active-PR — versioned TDT/CHRONOS composition is executable in existing `event_core` on this PR (segmentation → span-grounded mentions → links → first-story → tracks → schema slots → forecasts), building on the isolated gates already on main; interval consistency, persistence, and JSON/JSON-LD/GraphML exports remain accepted-target **Date:** 2026-08-12 -**Decision status:** Accepted -**Implementation maturity:** active-PR — bounded predicted-vs-observed Allen promotion gate, including coverage before unmatched predicted mass may be authorized for promotion; TDT detection/tracking, CHRONOS schema extraction, prediction calibration, and path-consistency laws remain accepted-target - -**Implementation maturity:** active-PR — evidence-layer admission and first-story detection rates are implemented in `event_core` on the active PR; full TDT tracking/calibration and CHRONOS schema extraction remain accepted-target. -**Implementation maturity:** active-PR — TDT tracking pair precision/recall, identity-switch rate, and track-versus-instance/transition refusal live in existing `event_core`; remaining TDT segmentation/first-story/link and CHRONOS schema/prediction layers remain accepted-target -**Implementation maturity:** active-PR — CHRONOS schema-slot precision/recall and prediction-versus-instance refusal live in existing `event_core`; remaining TDT detection/tracking and symbolic temporal-consistency layers remain accepted-target -**Implementation maturity:** active-PR — TDT story-segmentation `WindowDiff`/`Pk`/boundary precision-recall and segmentation-versus-instance/transition refusal live in existing `event_core`; remaining TDT link/tracking/first-story and CHRONOS schema/prediction layers remain accepted-target -**Implementation maturity:** active-PR — `event_core` scores CHRONOS occurrence forecasts with a Brier rule and refuses to promote them as instances; remaining TDT detection, schema extraction, and temporal-consistency reasoning remain accepted-target -**Date:** 2026-08-12 -**Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 temporal semantics and ADR 0003 event ontology/membership. ## Context diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index cd6b04e5..98734825 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -63,6 +63,20 @@ def _parse_branch_record(record: object) -> tuple[tuple[int, int, int, int], int return coordinates, true_count, false_count + +def is_live_sqlx_transport_source(filename: str) -> bool: + """Return whether *filename* is the live-server SQLx transport source. + + The authored LLVM coverage gate excludes ``sqlx_live.rs`` because a live + PostgreSQL server is required for the success path. Unreachable-host + failure remains unit-tested. The branch fold must honor the same + filename ignore that ``cargo llvm-cov --ignore-filename-regex`` uses, + or ignored live-transport arms re-enter the unique-site contract. + """ + + return Path(filename).name == "sqlx_live.rs" + + def fold_unique_branch_totals(files: object) -> dict[str, int] | None: """Return unique-site True/False arm totals, or None when arrays are absent. @@ -89,6 +103,8 @@ def fold_unique_branch_totals(files: object) -> dict[str, int] | None: filename = file_entry.get("filename") if not isinstance(filename, str) or not filename: raise ValueError("coverage JSON file entry must contain a filename") + if is_live_sqlx_transport_source(filename): + continue for record in records: site, true_count, false_count = _parse_branch_record(record) saw_records = True diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 897fb206..c55c0d55 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -221,6 +221,85 @@ def test_unique_branch_fold_overrides_phantom_json_totals(self) -> None: with self.assertRaisesRegex(ValueError, "incomplete: 1/2"): coverage_contract.validate_report(uncovered_false, ["branches"]) + + def test_live_sqlx_transport_branches_do_not_reenter_unique_fold(self) -> None: + """sqlx_live.rs arms stay outside the unique-site branch contract. + + cargo llvm-cov already ignores that filename, but the JSON file + arrays can still carry its live-server success-path arms. The fold + must drop them so the gate matches the documented transport ignore. + """ + + self.assertTrue( + coverage_contract.is_live_sqlx_transport_source( + "/home/runner/work/TEPP/TEPP/crates/persistence_postgres/src/sqlx_live.rs" + ) + ) + self.assertTrue(coverage_contract.is_live_sqlx_transport_source("sqlx_live.rs")) + self.assertFalse( + coverage_contract.is_live_sqlx_transport_source( + "crates/event_core/src/criterion_posterior.rs" + ) + ) + + with tempfile.TemporaryDirectory() as temporary: + mixed = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 8, "covered": 2}, + }, + "files": [ + { + "filename": ( + "crates/persistence_postgres/src/sqlx_live.rs" + ), + "branches": [ + [25, 1, 25, 8, 0, 0, 0, 0, 4], + [88, 1, 88, 8, 1, 0, 0, 0, 4], + ], + }, + { + "filename": "crates/event_core/src/criterion_posterior.rs", + "branches": [[108, 1, 108, 8, 2, 3, 0, 0, 4]], + }, + ], + } + ] + }, + ) + self.assertEqual( + coverage_contract.validate_report(mixed, ["branches"]), + ["branches coverage: PASS (2/2, 100%)"], + ) + + only_live = self.write_report( + temporary, + { + "data": [ + { + "totals": { + "lines": {"count": 1, "covered": 1}, + "branches": {"count": 2, "covered": 2}, + }, + "files": [ + { + "filename": "sqlx_live.rs", + "branches": [[25, 1, 25, 8, 0, 0, 0, 0, 4]], + } + ], + } + ] + }, + ) + self.assertEqual( + coverage_contract.validate_report(only_live, ["branches"]), + ["branches coverage: PASS (2/2, 100%)"], + ) + def test_malformed_unique_branch_records_fail_closed(self) -> None: """Absent filenames, short tuples, and non-integer counts are rejected.""" diff --git a/tests/quality/test_check_workspace_contract.py b/tests/quality/test_check_workspace_contract.py index 1435a431..12056a4c 100644 --- a/tests/quality/test_check_workspace_contract.py +++ b/tests/quality/test_check_workspace_contract.py @@ -167,7 +167,7 @@ def test_invalid_root_and_crate_contracts_are_reported(self) -> None: "missing_docs is not explicitly denied", "placeholder production APIs", "package identity contract test", - "temporal_core/Cargo.toml is missing", + "crates/temporal_core/Cargo.toml is missing", ) for fragment in expected_fragments: self.assertTrue(