-
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 3 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 |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| //! 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, | ||
| }; | ||
|
|
||
| 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() | ||
| } | ||
|
|
||
| fn decide_all(scores: &[f64], threshold: f64) -> Vec<FirstStoryLabel> { | ||
| let cut = EventConfidence::new(threshold).expect("threshold"); | ||
| scores | ||
| .iter() | ||
| .map(|score| decide_first_story(EventConfidence::new(*score).expect("score"), cut)) | ||
| .collect() | ||
| } | ||
|
|
||
| #[test] | ||
| fn first_story_detection_cannot_be_cast_to_an_instance() { | ||
| assert_eq!( | ||
| refuse_first_story_as_instance(EventMentionId::new()), | ||
| Err(EventError::FirstStoryIsNotEventInstance) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn false_alarm_and_miss_rates_are_computed_from_known_truth() { | ||
| let truth = [ | ||
| FirstStoryLabel::FirstStory, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FirstStory, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FollowUp, | ||
| ]; | ||
| let calibrated = decide_all(&[0.90, 0.10, 0.15, 0.85, 0.20, 0.05], 0.50); | ||
| let always_first = decide_all(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 0.50); | ||
|
|
||
| let calibrated_far = first_story_false_alarm_rate(&truth, &calibrated).expect("far"); | ||
| let naive_far = first_story_false_alarm_rate(&truth, &always_first).expect("naive far"); | ||
| let calibrated_miss = first_story_miss_rate(&truth, &calibrated).expect("miss"); | ||
| let naive_miss = first_story_miss_rate(&truth, &always_first).expect("naive miss"); | ||
|
|
||
| assert!( | ||
| calibrated_far < naive_far, | ||
| "computed FAR {calibrated_far} must be below always-first FAR {naive_far}" | ||
| ); | ||
| assert!(calibrated_miss <= naive_miss); | ||
| } | ||
|
|
||
| #[test] | ||
| fn calibrated_first_story_scores_have_lower_rmse_than_always_first() { | ||
| let truth_labels = [ | ||
| FirstStoryLabel::FirstStory, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FirstStory, | ||
| FirstStoryLabel::FollowUp, | ||
| FirstStoryLabel::FollowUp, | ||
| ]; | ||
| let truth: Vec<f64> = truth_labels | ||
| .iter() | ||
| .copied() | ||
| .map(FirstStoryLabel::as_probability_target) | ||
| .collect(); | ||
| let calibrated = [0.90_f64, 0.10, 0.15, 0.85, 0.20, 0.05]; | ||
| let always_first = [1.0_f64, 1.0, 1.0, 1.0, 1.0, 1.0]; | ||
| let calibrated_rmse = computed_rmse(&truth, &calibrated); | ||
| let naive_rmse = computed_rmse(&truth, &always_first); | ||
| assert!( | ||
| calibrated_rmse < naive_rmse, | ||
| "computed calibrated RMSE {calibrated_rmse} must be below always-first RMSE {naive_rmse}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn rate_helpers_fail_closed_on_empty_mismatch_and_missing_class() { | ||
| let first = [FirstStoryLabel::FirstStory]; | ||
| let follow = [FirstStoryLabel::FollowUp]; | ||
| assert_eq!( | ||
| first_story_false_alarm_rate(&[], &[]), | ||
| Err(EventError::InvalidWirePayload) | ||
| ); | ||
| assert_eq!( | ||
| first_story_miss_rate(&first, &[]), | ||
| Err(EventError::InvalidWirePayload) | ||
| ); | ||
| assert_eq!( | ||
| first_story_false_alarm_rate(&first, &first), | ||
| Err(EventError::InvalidWirePayload) | ||
| ); | ||
| assert_eq!( | ||
| first_story_miss_rate(&follow, &follow), | ||
| Err(EventError::InvalidWirePayload) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn labels_round_trip_and_threshold_is_inclusive() { | ||
| assert_eq!(FirstStoryLabel::FirstStory.wire_name(), "first_story"); | ||
| assert_eq!(FirstStoryLabel::FollowUp.wire_name(), "follow_up"); | ||
| assert_eq!( | ||
| FirstStoryLabel::from_wire_name("first_story").expect("parse"), | ||
| FirstStoryLabel::FirstStory | ||
| ); | ||
| assert_eq!( | ||
| FirstStoryLabel::from_wire_name("follow_up").expect("parse"), | ||
| FirstStoryLabel::FollowUp | ||
| ); | ||
| assert_eq!( | ||
| FirstStoryLabel::from_wire_name("maybe_new"), | ||
| Err(EventError::UnknownFirstStoryLabel) | ||
| ); | ||
| assert!(FirstStoryLabel::FirstStory.is_first_story()); | ||
| assert!(!FirstStoryLabel::FollowUp.is_first_story()); | ||
| assert!((FirstStoryLabel::FirstStory.as_probability_target() - 1.0).abs() < f64::EPSILON); | ||
| assert!((FirstStoryLabel::FollowUp.as_probability_target() - 0.0).abs() < f64::EPSILON); | ||
|
|
||
| let half = EventConfidence::new(0.5).expect("half"); | ||
| assert_eq!(decide_first_story(half, half), FirstStoryLabel::FirstStory); | ||
| assert_eq!( | ||
| decide_first_story(EventConfidence::new(0.49).expect("below"), half), | ||
| FirstStoryLabel::FollowUp | ||
| ); | ||
| } |
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.