diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 74c2400f..e6dafbcc 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -60,7 +60,7 @@ boundaries above remain the target modular MSA architecture. | `semantic_core` | span-grounded semantic units; language is not identity | | `location_membership` | location is not entity identity and not a language channel | | `temporal_core` | typed clocks, intervals, and temporal reasoning | -| `event_core` | event instances, mentions, roles, provenance, and CHRONOS occurrence-prediction calibration | +| `event_core` | event instances, span-grounded `EventMention` (the only constructible mention type), roles, provenance, and CHRONOS occurrence-prediction calibration | | `relation_graph` | typed relations and forward-transition validation | | `membership_core` | time-varying cross-classified multiple membership, Kish ESS, nested ICC with non-nested refusal | | `role_contradiction` | customer and competitor cannot occupy the same group | diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b0d3ae..990f786d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, p. 16 `MANIFESTMEANSstd`; Table 2, p. 12; footnote 4; Eq. 5, p. 5; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-25T11:32Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest mean on current main after `0ce16e8` restored the pre-consolidation unstandardised slice. Page 16 prints standardised matrices with the suffix `std` when appropriate. Footnote 4 standardises using only the relevant variance, not the total. Table 2 names `MANIFESTMEANS` `τ` the `n.manifest × 1` matrix of manifest means and `MANIFESTVAR` `Θ` the residual covariance of the indicators. The relevant variance for that named measurement intercept is residual `θ`, not total observed `Var(y) = λ² Var(η) + θ`. The 2017-era source forms unstandardised `MANIFESTMEANS` and does not form a `MANIFESTMEANSstd` matrix; the scalar map is `τ / √θ` after strictly positive `θ`. A zero mean is exactly zero. Zero `θ` has no positive SD and fails closed. A non-event clock fails closed. `MANIFESTMEANS` does not require stable `a < 0`. `θ / θ = 1` recovers the same number when `τ = √θ` and remains a distinct named quantity. `τ / √(λ² Var(η) + θ)` uses total observed variance and is not this residual map. Meredith (1993) remains unread (Unpaywall 2026-08-25T11:32Z: `is_oa: false`, 0 locations; OpenAlex closed; Springer `content/pdf` is a 3038-byte HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread (Unpaywall 2026-08-25T11:32Z: `is_oa: false`, 0 locations). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, p. 16 `CINTstd`; Eq. 1, p. 4; Table 2, p. 12; footnote 4; §7.1, pp. 18–19; JSS PDF re-opened 2026-08-25T11:43Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised continuous intercept on current main after consolidation dropped the pre-consolidation `κ / √p` slice. Page 16 prints standardised matrices with the suffix `std` when appropriate. Footnote 4 standardises using only the relevant variance, not the total. Table 2 names `κ` `CINT`. The relevant variance for that process intercept is within-subject `asymDIFFUSION` `p = −q / (2 a)`, not total `trait + p + added`. Form strictly positive `p` first, then `κ / √p`. A zero intercept is exactly zero after that positive SD. Zero `q` has no positive process SD and fails closed. Lasting `p` requires stable `a < 0`. A non-event clock fails closed. `(-κ / a) / √p` is `asymCINTstd` and is not this continuous intercept. `A^{-1}[e^{A Δt} − I] κ / √p` is `discreteCINTstd` and depends on the event interval. `κ / √(trait + p + added)` uses the total and is not this residual map. `TRAITVAR` is not the standardisation variance. Meredith (1993) remains unread (Unpaywall 2026-08-25T11:43Z: `is_oa: false`; Springer `content/pdf` is a 3038-byte HTML stub). Mislevy (1991) remains unread on the same terms. Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. - Operator-gap register refreshed against protected main `c482ccea` (#239): workspace version 0.2.0, 57 crates, queue at seven open PRs with exact-head SHAs, issues #156/#168/#175 closed, GAP-009 advanced to estimator-core-plus-repairs with Leiden consensus remaining, GAP-001/GAP-012/GAP-017 closed on protected main, and a new Post-#239 state note recording both the estimator repairs and the non-release status of the version bump. Supersedes #236 whose base predated #239. +- `event_core` `EventMention` is the only constructible mention type and must be span-grounded: it cites one exact `SourceSpan`, document identity, six-clock evidence, extractor/model version, and proposed/reviewed status, and the surface is the document substring at that span. Exact-extent precision/recall recover known ACE extents with occupancy RMSE 0 versus whole-document RMSE 1; reviewed status and TDT/CHRONOS detections still cannot promote an instance. Interval consistency, persistence, and exports remain later #170 work. - Restored protected-main gate integrity after the consolidation merges: hourly-scheduler prompt-contract tests now assert the gap-baseline-derived task contract (Gap ID naming, no invented weights) instead of stale increment-specific tokens, the operator-gap register inventory matches the live 33-PR queue, `evidence_core::image_unit` non-image/empty-subtype refusals and `load_union_branch_totals` valid-record accumulation have exact coverage, and the README crate fence plus duplicate registry entries stay deduped. - Branch-coverage diagnostics on the post-consolidation head exposed two uncovered outcomes in `evidence_core::image_unit` (`is_image_media_type_token` non-image prefix and empty-subtype refusals), one uncovered authored line (the strip-prefix refusal), and lost valid-record coverage for `load_union_branch_totals`; exact red-to-green cases now cover the non-image/empty-subtype data URIs and per-coordinate True/False accumulation. - Repaired post-consolidation merge fallout that left protected `main` red: restored the lost `return True` in the `check_coverage.py` match-guard branch, removed the shadowed duplicate `load_union_branch_totals` and `_is_multiline_match_guard` definitions plus duplicate workspace-crate entries (`episode_membership`, `analysis_engine`) from the contract tuple and Cargo member arrays, split two union-fused four-tuples back into `(variant, message)` pairs in the `event_core` error table, repaired the fused `identity_recovery_rate` body in `episode_membership::window`, deduplicated the checked-arithmetic eligible-count block in `analysis_engine`, fixed four-argument `unit()` test call sites, rebalanced the README crate-list fence around all 54 unique crates, and deduplicated the `location_membership`/`validation_core`/`tepp_api` architecture-table rows. Also documents private `PLAUSIBLE_IMAGE_MEDIA_TYPES` so `cargo doc -D warnings` passes. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a2ca132f..6fa4b968 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -44,6 +44,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Rubin total-variance doctoring | [`docs/research/rubin-total-variance.md`](docs/research/rubin-total-variance.md) | | Strong-invariance latent-mean doctoring | [`docs/research/strong-invariance-latent-means.md`](docs/research/strong-invariance-latent-means.md) | | Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | +| Span-grounded mention extent doctoring | [`docs/research/span-grounded-mentions.md`](docs/research/span-grounded-mentions.md) | +| Span-grounded mention numeric provenance | [`docs/doctoring/span-grounded-mentions.md`](docs/doctoring/span-grounded-mentions.md) | | Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | | TDT link-detection precision/recall doctoring | [`docs/research/event-link-detection-calibration.md`](docs/research/event-link-detection-calibration.md) | | First-story detection FAR/miss doctoring | [`docs/research/first-story-detection-calibration.md`](docs/research/first-story-detection-calibration.md) | @@ -61,6 +63,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Relation absence is not negative evidence | [`docs/research/relation-absence-not-negative.md`](docs/research/relation-absence-not-negative.md) | | Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | | Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | +| Span-grounded mention extent doctoring | [`docs/research/span-grounded-mentions.md`](docs/research/span-grounded-mentions.md) | +| Span-grounded mention numeric provenance | [`docs/doctoring/span-grounded-mentions.md`](docs/doctoring/span-grounded-mentions.md) | | Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | | Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | | Topic log-ratio coordinate doctoring | [`docs/research/topic-logratio-coordinates.md`](docs/research/topic-logratio-coordinates.md) | @@ -131,6 +135,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | | Mention-confidence Brier doctoring | [`docs/research/mention-confidence-brier.md`](docs/research/mention-confidence-brier.md) | +| Span-grounded mention extent doctoring | [`docs/research/span-grounded-mentions.md`](docs/research/span-grounded-mentions.md) | +| Span-grounded mention numeric provenance | [`docs/doctoring/span-grounded-mentions.md`](docs/doctoring/span-grounded-mentions.md) | | Event-intelligence status-gate doctoring | [`docs/research/event-intelligence-status-gates.md`](docs/research/event-intelligence-status-gates.md) | | VRAM budget / GPU fallback doctoring | [`docs/research/vram-budget-types.md`](docs/research/vram-budget-types.md) | | Causal-identification gate doctoring | [`docs/research/causal-identification-gate.md`](docs/research/causal-identification-gate.md) | diff --git a/crates/event_core/src/error.rs b/crates/event_core/src/error.rs index cf6bd4f7..37ba2bab 100644 --- a/crates/event_core/src/error.rs +++ b/crates/event_core/src/error.rs @@ -56,6 +56,16 @@ pub enum EventError { PredictionIsNotEventInstance, /// An unknown occurrence-truth label was supplied. UnknownOccurrenceTruth, + /// A span-grounded mention was treated as an event instance. + SpanMentionIsNotEventInstance, + /// Mention availability is after the knowledge cutoff. + MentionIneligibleAtCutoff, + /// The extractor or model version was empty or whitespace-only. + EmptyExtractorVersion, + /// The mention span does not belong to the supplied document. + MentionSpanDocumentMismatch, + /// An unknown mention-review status name was supplied. + UnknownMentionReviewStatus, } impl fmt::Display for EventError { @@ -94,6 +104,11 @@ impl fmt::Display for EventError { Self::UnknownStoryBoundaryLabel => "unknown story boundary label", Self::PredictionIsNotEventInstance => "CHRONOS prediction is not an event instance", Self::UnknownOccurrenceTruth => "unknown occurrence truth label", + Self::SpanMentionIsNotEventInstance => "span-grounded mention is not an event instance", + Self::MentionIneligibleAtCutoff => "mention availability is after the knowledge cutoff", + Self::EmptyExtractorVersion => "empty extractor version", + Self::MentionSpanDocumentMismatch => "mention span does not belong to the document", + Self::UnknownMentionReviewStatus => "unknown mention review status", }; formatter.write_str(message) } @@ -106,6 +121,7 @@ mod tests { use super::EventError; #[test] + #[allow(clippy::too_many_lines)] fn error_messages_are_stable() { for (error, message) in [ ( @@ -199,6 +215,23 @@ mod tests { EventError::UnknownOccurrenceTruth, "unknown occurrence truth label", ), + ( + EventError::SpanMentionIsNotEventInstance, + "span-grounded mention is not an event instance", + ), + ( + EventError::MentionIneligibleAtCutoff, + "mention availability is after the knowledge cutoff", + ), + (EventError::EmptyExtractorVersion, "empty extractor version"), + ( + EventError::MentionSpanDocumentMismatch, + "mention span does not belong to the document", + ), + ( + EventError::UnknownMentionReviewStatus, + "unknown mention review status", + ), ] { assert_eq!(error.to_string(), message); } diff --git a/crates/event_core/src/lib.rs b/crates/event_core/src/lib.rs index 4c8eb716..122d154e 100644 --- a/crates/event_core/src/lib.rs +++ b/crates/event_core/src/lib.rs @@ -4,15 +4,15 @@ //! //! TEPP separates **fallible event mentions** grounded in evidence from //! **versioned event instances** used for temporal state, multilevel membership, -//! and scientific estimation. Mentions and first-story detections never -//! silently become instances, and TDT detections and CHRONOS predictions -//! remain measurement or hypothesis artifacts until independently promoted. -//! and scientific estimation. Mentions never silently become instances. TDT -//! detections and CHRONOS predictions remain measurement or hypothesis -//! artifacts until independently promoted. 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. +//! and scientific estimation. [`EventMention`] is the only constructible +//! mention type: it cites one exact source extent, document identity, +//! 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. mod confidence; mod error; @@ -27,6 +27,7 @@ mod registry; mod role; mod schema; mod segment; +mod span_mention; mod track; /// Finite confidence on the closed unit interval. @@ -81,8 +82,10 @@ pub use link::event_link_recall; pub use link::refuse_event_link_as_instance; /// Explicit refusal to treat a TDT link as a state transition. pub use link::refuse_event_link_as_transition; -/// Fallible textual event mention. +/// Fallible textual event mention grounded in one exact source extent. pub use mention::EventMention; +/// Explicit refusal to treat a span-grounded mention as an instance. +pub use mention::refuse_span_mention_as_instance; /// One CHRONOS occurrence forecast that remains hypothetical. pub use prediction::ChronosOccurrenceForecast; /// Opaque CHRONOS occurrence-prediction identity. @@ -131,6 +134,14 @@ pub use segment::story_boundary_recall; pub use segment::story_pk; /// Pevzner–Hearst `WindowDiff` against a known-truth segmentation. pub use segment::story_window_diff; +/// Six-clock evidence bound to one mention. +pub use span_mention::MentionEvidenceClocks; +/// Proposed-versus-reviewed mention inspection status. +pub use span_mention::MentionReviewStatus; +/// Precision of recovered mention extents against known truth. +pub use span_mention::mention_span_precision; +/// Recall of recovered mention extents against known truth. +pub use span_mention::mention_span_recall; /// Assignment of one mention to one hypothesized TDT track. pub use track::EventTrackAssignment; /// Opaque TDT track identity. diff --git a/crates/event_core/src/mention.rs b/crates/event_core/src/mention.rs index c38d97a5..2e085d1f 100644 --- a/crates/event_core/src/mention.rs +++ b/crates/event_core/src/mention.rs @@ -1,11 +1,15 @@ -//! Fallible event mentions grounded in evidence. +//! Fallible event mentions grounded in one exact source extent. -use crate::{EventConfidence, EventError, EventMentionId}; -use evidence_core::EvidenceId; +use crate::span_mention::{MentionEvidenceClocks, MentionReviewStatus}; +use crate::{EventConfidence, EventError, EventEvidenceLayer, EventInstanceId, EventMentionId}; +use evidence_core::{DocumentRecord, EvidenceId, SourceSpan}; /// A fallible textual event mention that is **not** an event instance. /// -/// Mentions may be wrong, incomplete, or contradictory. Psychometric and +/// Mentions may be wrong, incomplete, or contradictory. Every mention cites +/// one exact [`SourceSpan`], document identity, six-clock evidence, extractor +/// or model version, and proposed-or-reviewed inspection status. The surface +/// form is the document substring selected by that span. Psychometric and /// temporal estimators must not treat a mention as a ground-truth event without /// an explicit promotion step that creates a distinct [`crate::EventInstance`]. #[derive(Clone, Debug, PartialEq)] @@ -14,29 +18,49 @@ pub struct EventMention { evidence_id: EvidenceId, surface_form: String, confidence: EventConfidence, + source_span: SourceSpan, + clocks: MentionEvidenceClocks, + extractor_version: String, + review_status: MentionReviewStatus, } impl EventMention { - /// Construct a validated event mention. + /// Bind a mention to one validated document span and six-clock evidence. + /// + /// The surface form is the document substring at `source_span`. Reviewed + /// status is authorized inspection and is not instance promotion. /// /// # Errors /// - /// Returns [`EventError::InvalidEventConfidence`] when confidence is invalid. - /// Empty surface forms are rejected as invalid wire/domain payloads. + /// Returns [`EventError::MentionSpanDocumentMismatch`] when the span does + /// not belong to `document`, or [`EventError::EmptyExtractorVersion`] when + /// the extractor version is empty or whitespace-only. pub fn new( - evidence_id: EvidenceId, - surface_form: impl Into, + document: &DocumentRecord, + source_span: SourceSpan, confidence: EventConfidence, + clocks: MentionEvidenceClocks, + extractor_version: impl Into, + review_status: MentionReviewStatus, ) -> Result { - let surface_form = surface_form.into(); - if surface_form.trim().is_empty() { - return Err(EventError::InvalidWirePayload); + if document.id() != source_span.document_id() { + return Err(EventError::MentionSpanDocumentMismatch); + } + let extractor_version = extractor_version.into(); + if extractor_version.trim().is_empty() { + return Err(EventError::EmptyExtractorVersion); } + let surface_form = + document.text()[source_span.byte_start()..source_span.byte_end()].to_string(); Ok(Self { mention_id: EventMentionId::new(), - evidence_id, + evidence_id: source_span.document_id(), surface_form, confidence, + source_span, + clocks, + extractor_version, + review_status, }) } @@ -46,13 +70,19 @@ impl EventMention { self.mention_id } + /// Return the grounding document identifier. + #[must_use] + pub const fn document_id(&self) -> EvidenceId { + self.source_span.document_id() + } + /// Return the grounding evidence identifier. #[must_use] pub const fn evidence_id(&self) -> EvidenceId { self.evidence_id } - /// Return the surface text. + /// Return the exact document substring selected by the span. #[must_use] pub fn surface_form(&self) -> &str { &self.surface_form @@ -63,4 +93,145 @@ impl EventMention { pub const fn confidence(&self) -> EventConfidence { self.confidence } + + /// Return the exact source extent. + #[must_use] + pub const fn source_span(&self) -> SourceSpan { + self.source_span + } + + /// Return the six-clock evidence. + #[must_use] + pub const fn clocks(&self) -> MentionEvidenceClocks { + self.clocks + } + + /// Return the extractor or model version. + #[must_use] + pub fn extractor_version(&self) -> &str { + &self.extractor_version + } + + /// Return the review status. + #[must_use] + pub const fn review_status(&self) -> MentionReviewStatus { + self.review_status + } + + /// Return the epistemic layer retained by the mention. + #[must_use] + pub const fn evidence_layer(&self) -> EventEvidenceLayer { + EventEvidenceLayer::ObservedMention + } +} + +/// Explicit refusal to treat a span-grounded mention as an event instance. +/// +/// # Errors +/// +/// Always returns [`EventError::SpanMentionIsNotEventInstance`]. +pub fn refuse_span_mention_as_instance( + _mention: &EventMention, +) -> Result { + Err(EventError::SpanMentionIsNotEventInstance) +} + +#[cfg(test)] +mod tests { + use super::{EventMention, refuse_span_mention_as_instance}; + use crate::span_mention::{MentionEvidenceClocks, MentionReviewStatus}; + use crate::{EventConfidence, EventError, EventEvidenceLayer}; + use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; + use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, + }; + + fn document(text: &str) -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), text).expect("document") + } + + fn span(document: &DocumentRecord, surface: &str) -> SourceSpan { + let byte_start = document.text().find(surface).expect("surface"); + 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-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("clocks") + } + + #[test] + fn grounded_mention_accessors_and_refusals_are_covered() { + let record = document("award filed"); + let mention = EventMention::new( + &record, + span(&record, "award"), + EventConfidence::certain().expect("certain"), + clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Reviewed, + ) + .expect("mention"); + let _ = mention.mention_id(); + assert_eq!(mention.document_id(), record.id()); + assert_eq!(mention.evidence_id(), record.id()); + assert_eq!(mention.surface_form(), "award"); + assert!((mention.confidence().value() - 1.0).abs() < f64::EPSILON); + assert_eq!(mention.source_span().byte_start(), 0); + assert_eq!(mention.clocks().available_time(), clocks().available_time()); + assert_eq!(mention.extractor_version(), "ace-extent-extractor/1"); + assert_eq!(mention.review_status(), MentionReviewStatus::Reviewed); + assert_eq!( + mention.evidence_layer(), + EventEvidenceLayer::ObservedMention + ); + assert_eq!( + refuse_span_mention_as_instance(&mention), + Err(EventError::SpanMentionIsNotEventInstance) + ); + let other = document("award filed"); + assert_eq!( + EventMention::new( + &other, + span(&record, "award"), + EventConfidence::certain().expect("certain"), + clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::MentionSpanDocumentMismatch) + ); + assert_eq!( + EventMention::new( + &record, + span(&record, "award"), + EventConfidence::certain().expect("certain"), + clocks(), + "", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::EmptyExtractorVersion) + ); + } } diff --git a/crates/event_core/src/span_mention.rs b/crates/event_core/src/span_mention.rs new file mode 100644 index 00000000..dce17b38 --- /dev/null +++ b/crates/event_core/src/span_mention.rs @@ -0,0 +1,309 @@ +//! Span identity, six-clock eligibility, and exact-extent recovery. + +use crate::EventError; +use evidence_core::SourceSpan; +use std::collections::BTreeSet; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +/// Review status of one fallible mention. +/// +/// Reviewed status is authorized inspection. It is not promotion into an +/// event instance or a forward state transition. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MentionReviewStatus { + /// The mention is hypothesized and has not been reviewed. + Proposed, + /// An authorized reviewer inspected the mention. + Reviewed, +} + +impl MentionReviewStatus { + /// Return the stable wire review-status name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Proposed => "proposed", + Self::Reviewed => "reviewed", + } + } + + /// Parse a stable wire review-status name. + /// + /// # Errors + /// + /// Returns [`EventError::UnknownMentionReviewStatus`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "proposed" => Ok(Self::Proposed), + "reviewed" => Ok(Self::Reviewed), + _ => Err(EventError::UnknownMentionReviewStatus), + } + } + + /// Return whether an authorized reviewer inspected the mention. + #[must_use] + pub const fn is_reviewed(self) -> bool { + matches!(self, Self::Reviewed) + } +} + +/// Six-clock evidence bound to one mention. +/// +/// Availability after the knowledge cutoff fails closed. Event time, assertion +/// time, and document time cannot replace availability. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MentionEvidenceClocks { + event_time: EventTime, + assertion_time: AssertionTime, + document_time: DocumentTime, + system_time: SystemTime, + available_time: AvailableTime, + knowledge_cutoff: KnowledgeCutoff, +} + +impl MentionEvidenceClocks { + /// Bind six typed clocks and refuse late availability. + /// + /// # Errors + /// + /// Returns [`EventError::MentionIneligibleAtCutoff`] when availability is + /// after the knowledge cutoff. + pub fn new( + event_time: EventTime, + assertion_time: AssertionTime, + document_time: DocumentTime, + system_time: SystemTime, + available_time: AvailableTime, + knowledge_cutoff: KnowledgeCutoff, + ) -> Result { + if available_time.instant().as_nanosecond() > knowledge_cutoff.instant().as_nanosecond() { + return Err(EventError::MentionIneligibleAtCutoff); + } + Ok(Self { + event_time, + assertion_time, + document_time, + system_time, + available_time, + knowledge_cutoff, + }) + } + + /// Return the claimed event time. + #[must_use] + pub const fn event_time(self) -> EventTime { + self.event_time + } + + /// Return the assertion time. + #[must_use] + pub const fn assertion_time(self) -> AssertionTime { + self.assertion_time + } + + /// Return the document time. + #[must_use] + pub const fn document_time(self) -> DocumentTime { + self.document_time + } + + /// Return the system time. + #[must_use] + pub const fn system_time(self) -> SystemTime { + self.system_time + } + + /// Return the availability time. + #[must_use] + pub const fn available_time(self) -> AvailableTime { + self.available_time + } + + /// Return the knowledge cutoff. + #[must_use] + pub const fn knowledge_cutoff(self) -> KnowledgeCutoff { + self.knowledge_cutoff + } +} + +/// Precision of recovered mention extents against known-truth extents. +/// +/// An extent matches when document identity and exact byte bounds agree. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when either extent set is empty +/// or a `(document, byte_start, byte_end)` triple is duplicated. +pub fn mention_span_precision( + truth: &[SourceSpan], + recovered: &[SourceSpan], +) -> Result { + let truth_spans = unique_extent_set(truth)?; + let recovered_spans = unique_extent_set(recovered)?; + counted_rate( + recovered_spans.intersection(&truth_spans).count(), + recovered_spans.len(), + ) +} + +/// Recall of recovered mention extents against known-truth extents. +/// +/// # Errors +/// +/// Returns [`EventError::InvalidWirePayload`] when either extent set is empty +/// or a `(document, byte_start, byte_end)` triple is duplicated. +pub fn mention_span_recall( + truth: &[SourceSpan], + recovered: &[SourceSpan], +) -> Result { + let truth_spans = unique_extent_set(truth)?; + let recovered_spans = unique_extent_set(recovered)?; + counted_rate( + recovered_spans.intersection(&truth_spans).count(), + truth_spans.len(), + ) +} + +fn unique_extent_set(spans: &[SourceSpan]) -> Result, EventError> { + if spans.is_empty() { + return Err(EventError::InvalidWirePayload); + } + let mut extents = BTreeSet::new(); + for span in spans { + if !extents.insert(( + span.document_id().as_uuid().as_u128(), + span.byte_start(), + span.byte_end(), + )) { + return Err(EventError::InvalidWirePayload); + } + } + Ok(extents) +} + +fn counted_rate(numerator: usize, denominator: usize) -> Result { + let numerator = u32::try_from(numerator).map_err(|_| EventError::InvalidWirePayload)?; + let denominator = u32::try_from(denominator).map_err(|_| EventError::InvalidWirePayload)?; + if denominator == 0 { + return Err(EventError::InvalidWirePayload); + } + Ok(f64::from(numerator) / f64::from(denominator)) +} + +#[cfg(test)] +mod tests { + use super::{ + MentionEvidenceClocks, MentionReviewStatus, counted_rate, mention_span_precision, + mention_span_recall, unique_extent_set, + }; + use crate::{ + EventConfidence, EventError, EventEvidenceLayer, EventMention, + refuse_span_mention_as_instance, + }; + use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; + use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, + }; + + fn document(text: &str) -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), text).expect("document") + } + + fn span(document: &DocumentRecord, surface: &str) -> SourceSpan { + let byte_start = document.text().find(surface).expect("surface"); + 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, cutoff: &str) -> Result { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339(available).expect("available"), + KnowledgeCutoff::parse_rfc3339(cutoff).expect("cutoff"), + ) + } + + #[test] + fn local_helpers_cover_review_clock_and_rate_branches() { + assert!(MentionReviewStatus::Reviewed.is_reviewed()); + assert!(!MentionReviewStatus::Proposed.is_reviewed()); + assert_eq!( + MentionReviewStatus::from_wire_name("proposed").expect("parse"), + MentionReviewStatus::Proposed + ); + assert_eq!(MentionReviewStatus::Reviewed.wire_name(), "reviewed"); + let cutoff_equal = + clocks_at("2026-03-31T00:00:00Z", "2026-03-31T00:00:00Z").expect("equal"); + assert_eq!( + cutoff_equal.event_time(), + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event") + ); + assert_eq!( + cutoff_equal.assertion_time(), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion") + ); + assert_eq!( + cutoff_equal.document_time(), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document") + ); + assert_eq!( + cutoff_equal.system_time(), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system") + ); + assert_eq!( + cutoff_equal.knowledge_cutoff(), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff") + ); + let record = document("award filed"); + let mention = EventMention::new( + &record, + span(&record, "award"), + EventConfidence::certain().expect("certain"), + cutoff_equal, + "ace-extent-extractor/1", + MentionReviewStatus::Reviewed, + ) + .expect("mention"); + assert_eq!( + refuse_span_mention_as_instance(&mention), + Err(EventError::SpanMentionIsNotEventInstance) + ); + let _ = mention.mention_id(); + assert_eq!( + mention.evidence_layer(), + EventEvidenceLayer::ObservedMention + ); + let award = span(&record, "award"); + assert!( + (mention_span_precision(&[award], &[award]).expect("p") - 1.0).abs() < f64::EPSILON + ); + assert!((mention_span_recall(&[award], &[award]).expect("r") - 1.0).abs() < f64::EPSILON); + assert_eq!(unique_extent_set(&[]), Err(EventError::InvalidWirePayload)); + assert_eq!(counted_rate(0, 0), Err(EventError::InvalidWirePayload)); + assert_eq!( + counted_rate(usize::MAX, 1), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + counted_rate(1, usize::MAX), + Err(EventError::InvalidWirePayload) + ); + assert!((counted_rate(1, 2).expect("half") - 0.5).abs() < f64::EPSILON); + } +} diff --git a/crates/event_core/tests/first_story_contract.rs b/crates/event_core/tests/first_story_contract.rs index 4a189c6e..d4f49566 100644 --- a/crates/event_core/tests/first_story_contract.rs +++ b/crates/event_core/tests/first_story_contract.rs @@ -1,9 +1,41 @@ //! First-story detections are not instances; FAR/miss are computed from truth. use event_core::{ - EventConfidence, EventError, EventMentionId, FirstStoryLabel, decide_first_story, - first_story_false_alarm_rate, first_story_miss_rate, refuse_first_story_as_instance, + EventConfidence, EventError, EventMention, EventMentionId, FirstStoryLabel, + MentionEvidenceClocks, MentionReviewStatus, decide_first_story, first_story_false_alarm_rate, + first_story_miss_rate, refuse_first_story_as_instance, }; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +fn grounded_mention_id() -> EventMentionId { + let text = "first story onset"; + let artifact = SourceArtifact::from_bytes(text.as_bytes()).expect("artifact"); + let document = DocumentRecord::from_text(artifact.id(), text).expect("document"); + let span = + SourceSpan::new(&document, 0, text.len(), 0, text.chars().count(), None).expect("span"); + let clocks = MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("clocks"); + EventMention::new( + &document, + span, + EventConfidence::new(0.8).expect("confidence"), + clocks, + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("grounded mention") + .mention_id() +} fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { assert_eq!(truth.len(), recovered.len()); @@ -30,7 +62,7 @@ fn decide_all(scores: &[f64], threshold: f64) -> Vec { #[test] fn first_story_detection_cannot_be_cast_to_an_instance() { assert_eq!( - refuse_first_story_as_instance(EventMentionId::new()), + refuse_first_story_as_instance(grounded_mention_id()), Err(EventError::FirstStoryIsNotEventInstance) ); } diff --git a/crates/event_core/tests/link_detection_contract.rs b/crates/event_core/tests/link_detection_contract.rs index 83389c1c..89c35754 100644 --- a/crates/event_core/tests/link_detection_contract.rs +++ b/crates/event_core/tests/link_detection_contract.rs @@ -1,10 +1,66 @@ //! TDT link detections are not instances; precision/recall come from truth. use event_core::{ - EventConfidence, EventError, EventLinkLabel, EventLinkPair, EventMentionId, decide_event_link, - event_link_precision, event_link_recall, refuse_event_link_as_instance, - refuse_event_link_as_transition, + EventConfidence, EventError, EventLinkLabel, EventLinkPair, EventMention, EventMentionId, + MentionEvidenceClocks, MentionReviewStatus, decide_event_link, event_link_precision, + event_link_recall, refuse_event_link_as_instance, refuse_event_link_as_transition, }; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +const DOCUMENT_TEXT: &str = "alpha bravo charlie delta echo foxtrot"; + +fn documentary_record() -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(DOCUMENT_TEXT.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), DOCUMENT_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 eligible_clocks() -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("eligible clocks") +} + +fn grounded_mention(surface: &str) -> EventMention { + let document = documentary_record(); + EventMention::new( + &document, + span_for(&document, surface), + EventConfidence::new(0.8).expect("confidence"), + eligible_clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("grounded mention") +} + +fn mention_id(surface: &str) -> EventMentionId { + grounded_mention(surface).mention_id() +} fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { assert_eq!(truth.len(), recovered.len()); @@ -26,8 +82,8 @@ fn pair(left: EventMentionId, right: EventMentionId) -> EventLinkPair { #[test] fn event_link_detection_cannot_be_cast_to_an_instance_or_transition() { - let left = EventMentionId::new(); - let right = EventMentionId::new(); + let left = mention_id("alpha"); + let right = mention_id("bravo"); let link = pair(left, right); assert_eq!( refuse_event_link_as_instance(link), @@ -41,10 +97,10 @@ fn event_link_detection_cannot_be_cast_to_an_instance_or_transition() { #[test] fn precision_and_recall_are_computed_from_known_truth_pairs() { - let a = EventMentionId::new(); - let b = EventMentionId::new(); - let c = EventMentionId::new(); - let d = EventMentionId::new(); + let a = mention_id("alpha"); + let b = mention_id("bravo"); + let c = mention_id("charlie"); + let d = mention_id("delta"); let truth = [pair(a, b), pair(b, c)]; let calibrated = [pair(a, b)]; let always_link = [pair(a, b), pair(b, c), pair(c, d), pair(a, d)]; @@ -79,13 +135,13 @@ fn calibrated_link_scores_have_lower_rmse_than_always_link() { #[test] fn pair_helpers_fail_closed_on_self_links_empty_and_missing_sets() { - let mention = EventMentionId::new(); + let mention = mention_id("echo"); assert_eq!( EventLinkPair::new(mention, mention), Err(EventError::InvalidWirePayload) ); - let a = EventMentionId::new(); - let b = EventMentionId::new(); + let a = mention_id("alpha"); + let b = mention_id("bravo"); let truth = [pair(a, b)]; assert_eq!( event_link_precision(&truth, &[]), @@ -125,8 +181,8 @@ fn labels_round_trip_and_threshold_is_inclusive() { EventLinkLabel::Unlinked ); - let left = EventMentionId::new(); - let right = EventMentionId::new(); + let left = mention_id("echo"); + let right = mention_id("foxtrot"); assert_eq!(pair(left, right), pair(right, left)); assert_ne!(pair(left, right).left(), pair(left, right).right()); } diff --git a/crates/event_core/tests/mention_instance_contract.rs b/crates/event_core/tests/mention_instance_contract.rs index 6aa48fe3..2ee6c0cb 100644 --- a/crates/event_core/tests/mention_instance_contract.rs +++ b/crates/event_core/tests/mention_instance_contract.rs @@ -2,45 +2,85 @@ use event_core::{ EventConfidence, EventError, EventEvidenceLayer, EventInstance, EventMention, EventRegistry, - EventRoleKind, refuse_mention_as_instance, + EventRoleKind, MentionEvidenceClocks, MentionReviewStatus, refuse_mention_as_instance, + refuse_span_mention_as_instance, }; -use evidence_core::{DocumentRecord, EvidenceId, SourceArtifact}; -use temporal_core::EventTime; - -fn document_evidence() -> EvidenceId { - let artifact = - SourceArtifact::from_bytes(b"contract award announced on 2026-03-01").expect("artifact"); - let document = - DocumentRecord::from_text(artifact.id(), "contract award announced on 2026-03-01") - .expect("document"); - document.id() +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +const DOCUMENT_TEXT: &str = "contract award announced on 2026-03-01 merger filed"; + +fn documentary_record() -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(DOCUMENT_TEXT.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), DOCUMENT_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 eligible_clocks() -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("eligible clocks") +} + +fn grounded_mention(surface: &str, confidence: EventConfidence) -> EventMention { + let document = documentary_record(); + EventMention::new( + &document, + span_for(&document, surface), + confidence, + eligible_clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("grounded mention") } #[test] fn mention_cannot_be_cast_to_instance_without_promotion() { - let evidence_id = document_evidence(); - let mention = EventMention::new( - evidence_id, + let mention = grounded_mention( "contract award announced", EventConfidence::new(0.72).expect("confidence"), - ) - .expect("mention"); + ); assert_eq!( refuse_mention_as_instance(mention.mention_id()), Err(EventError::MentionIsNotEventInstance) ); + assert_eq!( + refuse_span_mention_as_instance(&mention), + Err(EventError::SpanMentionIsNotEventInstance) + ); } #[test] fn registry_requires_supporting_mentions_before_instance_insert() { - let evidence_id = document_evidence(); - let mention = EventMention::new( - evidence_id, + let mention = grounded_mention( "contract award announced", EventConfidence::new(0.8).expect("confidence"), - ) - .expect("mention"); + ); let start = EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("start"); let end = EventTime::parse_rfc3339("2026-03-01T23:59:59Z").expect("end"); @@ -77,7 +117,14 @@ fn registry_requires_supporting_mentions_before_instance_insert() { assert_eq!(registry.mention_count(), 1); assert_eq!(registry.instance_count(), 1); - assert!(registry.mention(mention.mention_id()).is_some()); + let stored_mention = registry + .mention(mention.mention_id()) + .expect("stored mention"); + assert_eq!(stored_mention.surface_form(), "contract award announced"); + assert_eq!( + stored_mention.source_span().byte_end() - stored_mention.source_span().byte_start(), + "contract award announced".len() + ); let stored = registry.instance(instance.instance_id()).expect("stored"); assert_eq!(stored.supporting_mentions(), &[mention.mention_id()]); assert_eq!( @@ -89,11 +136,20 @@ fn registry_requires_supporting_mentions_before_instance_insert() { } #[test] -fn empty_surface_and_empty_mention_sets_fail_closed() { - let evidence_id = document_evidence(); +fn empty_extractor_and_empty_mention_sets_fail_closed() { + let document = documentary_record(); + let span = span_for(&document, "award"); assert_eq!( - EventMention::new(evidence_id, " ", EventConfidence::new(0.5).expect("c")).map(|_| ()), - Err(EventError::InvalidWirePayload) + EventMention::new( + &document, + span, + EventConfidence::new(0.5).expect("c"), + eligible_clocks(), + " ", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::EmptyExtractorVersion) ); let start = EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("start"); let end = EventTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("end"); @@ -112,15 +168,11 @@ fn empty_surface_and_empty_mention_sets_fail_closed() { #[test] fn accessors_and_duplicate_identity_paths_are_covered() { - let evidence_id = document_evidence(); - let mention = EventMention::new( - evidence_id, - "merger filed", - EventConfidence::certain().expect("certain"), - ) - .expect("mention"); - assert_eq!(mention.evidence_id(), evidence_id); + let mention = grounded_mention("merger filed", EventConfidence::certain().expect("certain")); assert_eq!(mention.surface_form(), "merger filed"); + assert_eq!(mention.document_id(), mention.evidence_id()); + assert_eq!(mention.extractor_version(), "ace-extent-extractor/1"); + assert_eq!(mention.review_status(), MentionReviewStatus::Proposed); assert!((mention.confidence().value() - 1.0).abs() < f64::EPSILON); assert_eq!(event_core::EVENT_INSTANCE_WIRE_SCHEMA_VERSION, 1); @@ -157,13 +209,10 @@ fn accessors_and_duplicate_identity_paths_are_covered() { #[test] fn promotion_rejects_every_non_promoted_evidence_layer() { - let evidence_id = document_evidence(); - let mention = EventMention::new( - evidence_id, + let mention = grounded_mention( "contract award announced", EventConfidence::certain().expect("confidence"), - ) - .expect("mention"); + ); let start = EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("start"); let end = EventTime::parse_rfc3339("2026-03-01T23:59:59Z").expect("end"); diff --git a/crates/event_core/tests/span_grounded_mention_contract.rs b/crates/event_core/tests/span_grounded_mention_contract.rs new file mode 100644 index 00000000..cf4980d7 --- /dev/null +++ b/crates/event_core/tests/span_grounded_mention_contract.rs @@ -0,0 +1,285 @@ +//! Span-grounded mentions recover exact ACE extents against known truth. + +use event_core::{ + EventConfidence, EventError, EventEvidenceLayer, EventMention, MentionEvidenceClocks, + MentionReviewStatus, mention_span_precision, mention_span_recall, + refuse_span_mention_as_instance, +}; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +const DOCUMENT_TEXT: &str = "The procurement office awarded the river-crossing contract on 1 March 2026 after the earlier protest was withdrawn."; + +fn documentary_record() -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(DOCUMENT_TEXT.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), DOCUMENT_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 eligible_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-02T08:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-03T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-03T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("eligible clocks") +} + +fn grounded(document: &DocumentRecord, surface: &str, review: MentionReviewStatus) -> EventMention { + EventMention::new( + document, + span_for(document, surface), + EventConfidence::new(0.91).expect("confidence"), + eligible_clocks(), + "ace-extent-extractor/1", + review, + ) + .expect("grounded mention") +} + +fn occupancy(truth: &[SourceSpan], recovered: &[SourceSpan]) -> Vec { + truth + .iter() + .map(|span| { + if recovered.iter().any(|candidate| { + candidate.document_id() == span.document_id() + && candidate.byte_start() == span.byte_start() + && candidate.byte_end() == span.byte_end() + }) { + 1.0 + } else { + 0.0 + } + }) + .collect() +} + +fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { + assert_eq!(truth.len(), recovered.len()); + let n = f64::from(u32::try_from(truth.len()).expect("tiny fixture")); + let sse: f64 = truth + .iter() + .zip(recovered) + .map(|(truth_value, recovered_value)| { + let residual = truth_value - recovered_value; + residual * residual + }) + .sum(); + (sse / n).sqrt() +} + +#[test] +fn span_mention_cannot_be_cast_to_an_instance() { + let document = documentary_record(); + let mention = grounded( + &document, + "awarded the river-crossing contract", + MentionReviewStatus::Reviewed, + ); + assert_eq!( + refuse_span_mention_as_instance(&mention), + Err(EventError::SpanMentionIsNotEventInstance) + ); + assert_eq!( + mention.evidence_layer(), + EventEvidenceLayer::ObservedMention + ); + assert_eq!(mention.evidence_layer().wire_name(), "observed_mention"); +} + +#[test] +fn exact_extractor_recovers_known_truth_extents_better_than_whole_document() { + let document = documentary_record(); + let award = span_for(&document, "awarded the river-crossing contract"); + let protest = span_for(&document, "protest"); + let truth = [award, protest]; + let exact = [award, protest]; + let whole_document = [span_for(&document, DOCUMENT_TEXT)]; + let shifted = [ + span_for(&document, "awarded the river-crossing"), + span_for(&document, "protest was"), + ]; + + let exact_precision = mention_span_precision(&truth, &exact).expect("exact p"); + let exact_recall = mention_span_recall(&truth, &exact).expect("exact r"); + let naive_precision = mention_span_precision(&truth, &whole_document).expect("naive p"); + let naive_recall = mention_span_recall(&truth, &whole_document).expect("naive r"); + let shifted_precision = mention_span_precision(&truth, &shifted).expect("shifted p"); + let shifted_recall = mention_span_recall(&truth, &shifted).expect("shifted r"); + + assert!((exact_precision - 1.0).abs() < f64::EPSILON); + assert!((exact_recall - 1.0).abs() < f64::EPSILON); + assert!(naive_precision.abs() < f64::EPSILON); + assert!(naive_recall.abs() < f64::EPSILON); + assert!(shifted_precision.abs() < f64::EPSILON); + assert!(shifted_recall.abs() < f64::EPSILON); + + let ones = [1.0, 1.0]; + let exact_rmse = computed_rmse(&ones, &occupancy(&truth, &exact)); + let naive_rmse = computed_rmse(&ones, &occupancy(&truth, &whole_document)); + assert!(exact_rmse.abs() < 1e-15); + assert!((naive_rmse - 1.0).abs() < 1e-15); + assert!(exact_rmse < naive_rmse); +} + +#[test] +fn grounded_mention_keeps_exact_surface_document_clocks_and_review_status() { + let document = documentary_record(); + let mention = grounded( + &document, + "awarded the river-crossing contract", + MentionReviewStatus::Proposed, + ); + assert_eq!( + mention.surface_form(), + "awarded the river-crossing contract" + ); + assert_eq!(mention.document_id(), document.id()); + assert_eq!(mention.evidence_id(), document.id()); + assert_eq!( + mention.source_span().byte_start(), + document + .text() + .find("awarded the river-crossing contract") + .expect("offset") + ); + assert_eq!(mention.extractor_version(), "ace-extent-extractor/1"); + assert_eq!(mention.review_status(), MentionReviewStatus::Proposed); + assert!((mention.confidence().value() - 0.91).abs() < f64::EPSILON); + assert_eq!( + mention.clocks().available_time(), + AvailableTime::parse_rfc3339("2026-03-03T00:00:00Z").expect("available") + ); + assert_eq!( + MentionReviewStatus::from_wire_name("reviewed").expect("parse"), + MentionReviewStatus::Reviewed + ); + assert_eq!(MentionReviewStatus::Proposed.wire_name(), "proposed"); +} + +#[test] +fn delayed_reporting_before_cutoff_is_kept_and_late_availability_fails_closed() { + let document = documentary_record(); + let span = span_for(&document, "protest"); + let late = MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T12:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-04-02T09:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-04-02T08:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-04-03T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-04-03T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ); + assert_eq!(late, Err(EventError::MentionIneligibleAtCutoff)); + + let delayed = MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T12:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-15T09:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-15T08:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-16T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-16T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("delayed but eligible"); + let mention = EventMention::new( + &document, + span, + EventConfidence::certain().expect("certain"), + delayed, + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("delayed mention"); + assert_eq!(mention.surface_form(), "protest"); +} + +#[test] +fn empty_extractor_foreign_span_and_empty_or_duplicate_extent_sets_fail_closed() { + let document = documentary_record(); + let other = documentary_record(); + let span = span_for(&document, "protest"); + assert_eq!( + EventMention::new( + &document, + span, + EventConfidence::certain().expect("certain"), + eligible_clocks(), + " ", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::EmptyExtractorVersion) + ); + assert_eq!( + EventMention::new( + &document, + span, + EventConfidence::certain().expect("certain"), + eligible_clocks(), + "", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::EmptyExtractorVersion) + ); + assert_eq!( + EventMention::new( + &other, + span, + EventConfidence::certain().expect("certain"), + eligible_clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .map(|_| ()), + Err(EventError::MentionSpanDocumentMismatch) + ); + assert_eq!( + mention_span_precision(&[], &[span]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + mention_span_recall(&[span], &[]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + mention_span_precision(&[span, span], &[span]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + mention_span_precision(&[span], &[span, span]), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + mention_span_recall(&[span], &[span, span]), + Err(EventError::InvalidWirePayload) + ); + let award = span_for(&document, "awarded the river-crossing contract"); + let mixed_precision = mention_span_precision(&[award], &[award, span]).expect("mixed"); + assert!((mixed_precision - 0.5).abs() < f64::EPSILON); + let mixed_recall = mention_span_recall(&[award, span], &[award]).expect("mixed recall"); + assert!((mixed_recall - 0.5).abs() < f64::EPSILON); + assert_eq!( + MentionReviewStatus::from_wire_name("promoted"), + Err(EventError::UnknownMentionReviewStatus) + ); +} diff --git a/crates/event_core/tests/tracking_contract.rs b/crates/event_core/tests/tracking_contract.rs index b22500d3..318e3456 100644 --- a/crates/event_core/tests/tracking_contract.rs +++ b/crates/event_core/tests/tracking_contract.rs @@ -1,10 +1,63 @@ //! TDT tracks are not instances; pair P/R and switch rate come from truth. use event_core::{ - EventConfidence, EventError, EventMentionId, EventTrackAssignment, EventTrackId, - EventTrackLabel, decide_track_continue, refuse_track_as_instance, refuse_track_as_transition, - tracking_identity_switch_rate, tracking_pair_precision, tracking_pair_recall, + EventConfidence, EventError, EventMention, EventMentionId, EventTrackAssignment, EventTrackId, + EventTrackLabel, MentionEvidenceClocks, MentionReviewStatus, decide_track_continue, + refuse_track_as_instance, refuse_track_as_transition, tracking_identity_switch_rate, + tracking_pair_precision, tracking_pair_recall, }; +use evidence_core::{DocumentRecord, SourceArtifact, SourceSpan}; +use temporal_core::{ + AssertionTime, AvailableTime, DocumentTime, EventTime, KnowledgeCutoff, SystemTime, +}; + +const DOCUMENT_TEXT: &str = "alpha bravo charlie delta echo foxtrot golf hotel"; + +fn documentary_record() -> DocumentRecord { + let artifact = SourceArtifact::from_bytes(DOCUMENT_TEXT.as_bytes()).expect("artifact"); + DocumentRecord::from_text(artifact.id(), DOCUMENT_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 eligible_clocks() -> MentionEvidenceClocks { + MentionEvidenceClocks::new( + EventTime::parse_rfc3339("2026-03-01T00:00:00Z").expect("event"), + AssertionTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("assertion"), + DocumentTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("document"), + SystemTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("system"), + AvailableTime::parse_rfc3339("2026-03-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-03-31T00:00:00Z").expect("cutoff"), + ) + .expect("eligible clocks") +} + +fn grounded_mention(surface: &str) -> EventMention { + let document = documentary_record(); + EventMention::new( + &document, + span_for(&document, surface), + EventConfidence::new(0.8).expect("confidence"), + eligible_clocks(), + "ace-extent-extractor/1", + MentionReviewStatus::Proposed, + ) + .expect("grounded mention") +} fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { assert_eq!(truth.len(), recovered.len()); @@ -21,7 +74,7 @@ fn computed_rmse(truth: &[f64], recovered: &[f64]) -> f64 { } fn mention() -> EventMentionId { - EventMentionId::new() + grounded_mention("alpha").mention_id() } fn assignment(mention_id: EventMentionId, track: u32) -> EventTrackAssignment { diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 066ab834..0cd66286 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -93,6 +93,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | `tepp_api::route_orchestration`, ablation record, and credential-free contextual-orchestrator binding on protected main; live execution and learned conductor calibration remain future | partial | | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization, elevated re-identification, and provider-payload minimization are implemented-main; migration `0007` retention/deletion/legal-hold SQL contracts are implemented-main; `encrypted_mapping` AES-256-GCM envelope is active on this PR; deployment/provider evidence remains accepted-target | active-PR | | tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial implemented-main; persistent `access_grant` storage remaining | partial | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` `EventMention` is the only constructible mention type and is span-grounded, with exact ACE extent precision/recall, six-clock cutoff eligibility, and review-status-is-not-promotion on this PR; remaining unified workflow, interval consistency, persistence, and exports stay accepted-target | active-PR | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | `tepp_api::route_orchestration` + ablation record on the active PR; live contextual-orchestrator execution remaining | partial | diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index 8f4976cf..addd29b3 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -1,6 +1,7 @@ # 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 **Date:** 2026-08-12 diff --git a/docs/adr/README.md b/docs/adr/README.md index 05f55a1c..0cc8c9f4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,7 +21,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility, and split authority | Accepted | partial | Migration, tenant, append-only, interval, and live SQL contracts are present; physical ERD and recovery depth remain target work. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence | Accepted | partial | Exact-head promotion authority and repository evidence exist; the complete release bundle remains target work. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Proposal, deterministic verification, publication, independent review, and merge/release authority remain separate. | -| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and event-intelligence boundary | Accepted | active-PR | Evidence admission, tracking, schema, segmentation, and Brier gates are active; complete intelligence remains target work. | +| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and event-intelligence boundary | Accepted | active-PR | Span-grounded mentions with exact-extent recovery are active on this PR; unified TDT/CHRONOS workflow, interval consistency, persistence, and exports remain target work. | | [0017](0017-hourly-contextual-orchestrator-gateway.md) | Hourly contextual-orchestrator gateway and provider discovery | Accepted | active-PR | Proposal-model execution is pinned behind a loopback gateway and remains separate from verification and merge authority. | | [0018](0018-consumer-scoped-analysis-run-ingress.md) | Consumer-scoped modular analysis-run ingress | Accepted | active-PR | Closed consumer registry, credential-free exchange, and consumer-qualified idempotency are active. | | [0019](0019-project-history-wire-size-symmetry.md) | Symmetric LineageWeave project-history wire-size enforcement | Accepted | active-PR | Request serialization and generated project-history projections share bounded size rules. | diff --git a/docs/doctoring/span-grounded-mentions.md b/docs/doctoring/span-grounded-mentions.md new file mode 100644 index 00000000..02d6aeb8 --- /dev/null +++ b/docs/doctoring/span-grounded-mentions.md @@ -0,0 +1,40 @@ +# Span-grounded event mentions (doctoring) + +## Claim boundary + +`EventMention` is the only constructible mention type. It binds a fallible +event mention to one exact `evidence_core::SourceSpan`, six typed clocks, an +extractor/model version, and a proposed-or-reviewed inspection status. The +surface form is the document substring at that span. Exact-extent precision +and recall are computed against known-truth spans. A reviewed mention is not +a promoted event instance. This slice does not run TDT tracking, CHRONOS +schema extraction, interval consistency, persistence, or GraphML/JSON-LD +export, and it does not close issue #170. + +## Numeric constants + +| Constant | Value | Provenance | +|---|---|---| +| extent identity | `(document_id, byte_start, byte_end)` | Doddington et al. (2004) map a system mention to a reference mention when their extents match. TEPP uses the already-validated `SourceSpan` byte bounds plus document identity. | +| availability eligibility | `available_time ≤ knowledge_cutoff` | ADR 0002 / `evaluate_historical_eligibility`: every available instant must be at or before cutoff. Instant equality is eligible. | +| precision | `\|recovered ∩ truth\| / \|recovered\|` | Standard set precision of mapped extents; no extra weight. | +| recall | `\|recovered ∩ truth\| / \|truth\|` | Standard set recall of mapped extents; no extra weight. | + +There is no probability threshold, similarity weight, or overlap heuristic. +Partial or shifted extents do not count. Empty or duplicated extent sets fail +closed. + +## Primary sources + +Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & +Weischedel, R. (2004). The Automatic Content Extraction (ACE) program—Tasks, +data, and evaluation. In *Proceedings of the Fourth International Conference +on Language Resources and Evaluation (LREC’04)* (pp. 837–840). European +Language Resources Association. + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based +information organization*. Kluwer Academic Publishers. + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE +Transactions on Knowledge and Data Engineering, 11*(1), 36–44. +https://doi.org/10.1109/69.755613 diff --git a/docs/research/span-grounded-mentions.md b/docs/research/span-grounded-mentions.md new file mode 100644 index 00000000..86204b2a --- /dev/null +++ b/docs/research/span-grounded-mentions.md @@ -0,0 +1,43 @@ +# Span-grounded event mentions + +## Scope + +This note doctors the `event_core` contract that keeps documentary event mentions +span-grounded and distinct from promoted instances: + +1. a mention cites one exact source extent, document identity, six-clock + evidence, extractor/model version, and review status; +2. exact-extent precision and recall are computed from known-truth spans; +3. a reviewed mention remains observed evidence and cannot promote an instance. + +No database migration is allocated. Interval consistency, persistence, and +versioned GraphML/JSON-LD exports remain later #170 work. + +## Authoritative sources + +Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & Weischedel, R. (2004). The Automatic Content Extraction (ACE) program—Tasks, data, and evaluation. In *Proceedings of the Fourth International Conference on Language Resources and Evaluation (LREC’04)* (pp. 837–840). European Language Resources Association. + +Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information organization*. Kluwer Academic Publishers. + +Jensen, C. S., & Snodgrass, R. T. (1999). Temporal data management. *IEEE Transactions on Knowledge and Data Engineering, 11*(1), 36–44. https://doi.org/10.1109/69.755613 + +## Application + +Doddington et al. (2004) evaluate ACE mentions by mapping system mentions to +reference mentions when their extents match. Allan (2002) keeps TDT detection +outputs in the measurement layer. Jensen and Snodgrass (1999) separate valid +time from transaction/availability time, so event time cannot replace the +cutoff test. TEPP therefore requires exact `(document, byte_start, byte_end)` +identity, refuses availability after cutoff, and refuses to cast a +span-grounded mention as an event instance (Doddington et al., 2004; Allan, +2002; Jensen & Snodgrass, 1999). Numeric provenance is recorded in +[`docs/doctoring/span-grounded-mentions.md`](../doctoring/span-grounded-mentions.md). + +## Verification + +- `refuse_span_mention_as_instance` always returns `SpanMentionIsNotEventInstance`; +- `EventMention::new` derives the surface from the exact document span and is the only mention constructor; +- availability after cutoff returns `MentionIneligibleAtCutoff`; +- delayed reporting at or before cutoff is kept; +- `mention_span_precision` and `mention_span_recall` fail closed on empty or duplicate extents; +- an exact extractor recovers precision 1 and recall 1, with occupancy RMSE 0, against a two-mention documentary fixture, while a whole-document extractor recovers precision 0, recall 0, and RMSE 1. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 700ce41a..b308a84e 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -213,6 +213,8 @@ Chambers, N., & Jurafsky, D. (2009). Unsupervised learning of narrative schemas Doddington, G., Mitchell, A., Przybocki, M., Ramshaw, L., Strassel, S., & Weischedel, R. (2004). The Automatic Content Extraction (ACE) program—Tasks, data, and evaluation. In *Proceedings of the Fourth International Conference on Language Resources and Evaluation (LREC’04)* (pp. 837–840). European Language Resources Association. +TEPP uses exact ACE mention-extent identity `(document, byte_start, byte_end)` for span-grounded event mentions. A system extent that does not match a reference extent is a miss or false alarm; review status is not instance promotion (Doddington et al., 2004; Allan, 2002). + TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks (Allan, 2002), qualitative temporal reasoning (Anagnostopoulos et al., 2013), and separate neural/symbolic event-schema and narrative participant-slot layers (Chambers & Jurafsky, 2009). Under [ADR 0016](../adr/0016-tdt-chronos-event-intelligence-boundary.md), predicted schema-slot fills stay hypothetical until independently promoted; this is a TEPP policy boundary, not a literature result. Brier, G. W. (1950). Verification of forecasts expressed in terms of probability. *Monthly Weather Review, 78*(1), 1–3. https://doi.org/10.1175/1520-0493(1950)078<0001:VOFEIT>2.0.CO;2 diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 929d084d..2668749f 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -19,6 +19,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Six-clock temporal | `temporal_core` | implemented-main | `document_clocks` omitted assertion/document time | unit + wire | Task 3 / PR #8; document-row clocks on this PR | | Knowledge-cutoff identity | `cutoff_clock` | active-PR | this PR | recovered cutoff flags vs availability-time stand-in | ADR 0002 | | Allen path-consistency | `temporal_core` | implemented-main | — | unit + budget tests | Task 4 / PR #9 | +| Span-grounded mentions | `event_core` | active-PR | this PR | `EventMention` is the only constructible mention type; exact-extent P/R + occupancy RMSE vs whole-document | ADR 0016; `docs/research/span-grounded-mentions.md` | | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | | TDT link precision/recall | `event_core` | active-PR | this PR | computed precision/recall + RMSE vs always-link | ADR 0016; `docs/research/event-link-detection-calibration.md` | | First-story FAR/miss | `event_core` | active-PR | this PR | computed FAR/miss + RMSE vs always-first | ADR 0016; `docs/research/first-story-detection-calibration.md` |