-
Notifications
You must be signed in to change notification settings - Fork 0
feat(event): score first-story detections with FAR and miss rates #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
366ca72
928f905
54bb76d
74351f3
050bac6
3fd7b39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| //! First-story detection scores stay distinct from promoted instances. | ||
|
|
||
| use crate::{EventConfidence, EventError, EventInstanceId, EventMentionId}; | ||
|
|
||
| /// TDT first-story versus follow-up label. | ||
| /// | ||
| /// A first-story decision is detection evidence. It is never a promoted event | ||
| /// instance and cannot create a forward state transition by itself. | ||
| #[derive(Clone, Copy, Debug, Eq, PartialEq)] | ||
| pub enum FirstStoryLabel { | ||
| /// The mention is scored as the onset of a new story. | ||
| FirstStory, | ||
| /// The mention is scored as a continuation of an earlier story. | ||
| FollowUp, | ||
| } | ||
|
|
||
| impl FirstStoryLabel { | ||
| /// Return the stable wire label name. | ||
| #[must_use] | ||
| pub const fn wire_name(self) -> &'static str { | ||
| match self { | ||
| Self::FirstStory => "first_story", | ||
| Self::FollowUp => "follow_up", | ||
| } | ||
| } | ||
|
|
||
| /// Parse a stable wire first-story label. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EventError::UnknownFirstStoryLabel`] for unrecognized names. | ||
| pub fn from_wire_name(name: &str) -> Result<Self, EventError> { | ||
| match name { | ||
| "first_story" => Ok(Self::FirstStory), | ||
| "follow_up" => Ok(Self::FollowUp), | ||
| _ => Err(EventError::UnknownFirstStoryLabel), | ||
| } | ||
| } | ||
|
|
||
| /// Return whether this label is a first-story detection. | ||
| #[must_use] | ||
| pub const fn is_first_story(self) -> bool { | ||
| matches!(self, Self::FirstStory) | ||
| } | ||
|
|
||
| /// Return the binary probability target used for RMSE. | ||
| /// | ||
| /// First-story truth is `1.0`; follow-up truth is `0.0`. | ||
| #[must_use] | ||
| pub const fn as_probability_target(self) -> f64 { | ||
| match self { | ||
| Self::FirstStory => 1.0, | ||
| Self::FollowUp => 0.0, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Threshold a first-story probability into a detection label. | ||
| /// | ||
| /// The threshold is inclusive: `probability >= threshold` is a first story. | ||
| #[must_use] | ||
| pub fn decide_first_story( | ||
| probability: EventConfidence, | ||
| threshold: EventConfidence, | ||
| ) -> FirstStoryLabel { | ||
| if probability.value() >= threshold.value() { | ||
| FirstStoryLabel::FirstStory | ||
| } else { | ||
| FirstStoryLabel::FollowUp | ||
| } | ||
| } | ||
|
|
||
| /// Explicit refusal to treat a first-story detection as an event instance. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Always returns [`EventError::FirstStoryIsNotEventInstance`]. | ||
| pub fn refuse_first_story_as_instance( | ||
| _mention_id: EventMentionId, | ||
| ) -> Result<EventInstanceId, EventError> { | ||
| Err(EventError::FirstStoryIsNotEventInstance) | ||
| } | ||
|
|
||
| /// False-alarm rate: follow-ups labeled first story, over follow-up truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EventError::InvalidWirePayload`] when lengths differ, either | ||
| /// slice is empty, or the truth stream contains no follow-up. | ||
| pub fn first_story_false_alarm_rate( | ||
| truth: &[FirstStoryLabel], | ||
| decided: &[FirstStoryLabel], | ||
| ) -> Result<f64, EventError> { | ||
| rate_over_class( | ||
| truth, | ||
| decided, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FirstStory, | ||
| ) | ||
| } | ||
|
|
||
| /// Miss rate: first stories labeled follow-up, over first-story truth. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EventError::InvalidWirePayload`] when lengths differ, either | ||
| /// slice is empty, or the truth stream contains no first story. | ||
| pub fn first_story_miss_rate( | ||
| truth: &[FirstStoryLabel], | ||
| decided: &[FirstStoryLabel], | ||
| ) -> Result<f64, EventError> { | ||
| rate_over_class( | ||
| truth, | ||
| decided, | ||
| FirstStoryLabel::FirstStory, | ||
| FirstStoryLabel::FollowUp, | ||
| ) | ||
| } | ||
|
|
||
| fn rate_over_class( | ||
| truth: &[FirstStoryLabel], | ||
| decided: &[FirstStoryLabel], | ||
| class: FirstStoryLabel, | ||
| error_label: FirstStoryLabel, | ||
| ) -> Result<f64, EventError> { | ||
| if truth.is_empty() || truth.len() != decided.len() { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| let mut class_count = 0_u32; | ||
| let mut error_count = 0_u32; | ||
| for (truth_label, decided_label) in truth.iter().zip(decided) { | ||
| if *truth_label == class { | ||
| class_count += 1; | ||
| if *decided_label == error_label { | ||
| error_count += 1; | ||
| } | ||
| } | ||
| } | ||
| if class_count == 0 { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| Ok(f64::from(error_count) / f64::from(class_count)) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
Comment on lines
+120
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Divergent empty-class behavior across two first-story APIs The new rate helpers in first_story.rs fail closed with Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::{ | ||
| FirstStoryLabel, decide_first_story, first_story_false_alarm_rate, first_story_miss_rate, | ||
| refuse_first_story_as_instance, | ||
| }; | ||
| use crate::{EventConfidence, EventError, EventMentionId}; | ||
|
|
||
| #[test] | ||
| fn first_story_helpers_cover_local_branches() { | ||
| let mention = EventMentionId::new(); | ||
| assert_eq!( | ||
| refuse_first_story_as_instance(mention), | ||
| Err(EventError::FirstStoryIsNotEventInstance) | ||
| ); | ||
| let high = EventConfidence::new(0.8).expect("high"); | ||
| let low = EventConfidence::new(0.2).expect("low"); | ||
| assert_eq!(decide_first_story(high, low), FirstStoryLabel::FirstStory); | ||
| assert_eq!(decide_first_story(low, high), FirstStoryLabel::FollowUp); | ||
| let mixed_truth = [FirstStoryLabel::FirstStory, FirstStoryLabel::FollowUp]; | ||
| let mixed_decided = [FirstStoryLabel::FollowUp, FirstStoryLabel::FirstStory]; | ||
| assert!( | ||
| (first_story_false_alarm_rate(&mixed_truth, &mixed_decided).expect("far") - 1.0).abs() | ||
| < f64::EPSILON | ||
| ); | ||
| assert!( | ||
| (first_story_miss_rate(&mixed_truth, &mixed_decided).expect("miss") - 1.0).abs() | ||
| < f64::EPSILON | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,9 @@ | |
| //! | ||
| //! 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. | ||
|
Comment on lines
+7
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Duplicated, incoherent module docstring The new summary sentence was added without removing the old overlapping text, so the module doc repeats "and scientific estimation. Mentions..." and the TDT/CHRONOS clause twice, producing a broken, self-contradicting paragraph. Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| //! and scientific estimation. Mentions never silently become instances. TDT | ||
| //! detections and CHRONOS predictions remain measurement or hypothesis | ||
| //! artifacts until independently promoted. Track assignments, story | ||
|
|
@@ -13,6 +16,7 @@ | |
|
|
||
| mod confidence; | ||
| mod error; | ||
| mod first_story; | ||
| mod identifier; | ||
| mod instance; | ||
| mod intelligence; | ||
|
|
@@ -30,6 +34,16 @@ pub use confidence::EventConfidence; | |
| pub use confidence::mention_brier_score; | ||
| /// Fail-closed event-ontology errors. | ||
| pub use error::EventError; | ||
| /// First-story versus follow-up detection label. | ||
| pub use first_story::FirstStoryLabel; | ||
| /// Threshold a first-story probability into a detection label. | ||
| pub use first_story::decide_first_story; | ||
| /// False-alarm rate for first-story detections. | ||
| pub use first_story::first_story_false_alarm_rate; | ||
| /// Miss rate for first-story detections. | ||
| pub use first_story::first_story_miss_rate; | ||
| /// Explicit refusal to treat a first-story detection as an instance. | ||
| pub use first_story::refuse_first_story_as_instance; | ||
| /// Opaque event-instance identifier. | ||
| pub use identifier::EventInstanceId; | ||
| /// Opaque event-mention identifier. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Merged test tuple drops two error assertions
The
UnknownFirstStoryLabeltest case is missing its closing paren and the reopening paren for the next case, so it fuses withEventTrackIsNotEventInstanceinto a single four-element tuple. The two variants are no longer asserted separately, and the array of two-element tuples no longer type-checks.Was this helpful? React with 👍 or 👎 to provide feedback.