Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

### Added

- `event_core` first-story detection gate: first-story versus follow-up labels stay distinct from promoted instances, false-alarm and miss rates are computed from known truth, and calibrated detection scores recover the binary first-story target with lower RMSE than an always-first detector.
- `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011).
- `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target.
- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure.
Expand Down
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| Hourly NIM product-development operations | [`docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md`](docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md) |
| 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) |
| First-story detection FAR/miss doctoring | [`docs/research/first-story-detection-calibration.md`](docs/research/first-story-detection-calibration.md) |
| Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) |
| Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) |
| Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) |
Expand Down
14 changes: 14 additions & 0 deletions crates/event_core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub enum EventError {
UnsupportedWireVersion,
/// An unknown event-role name was supplied.
UnknownEventRole,
/// A first-story detection was treated as an event instance.
FirstStoryIsNotEventInstance,
/// An unknown first-story label name was supplied.
UnknownFirstStoryLabel,
}

impl fmt::Display for EventError {
Expand All @@ -32,6 +36,8 @@ impl fmt::Display for EventError {
Self::InvalidWirePayload => "invalid event wire payload",
Self::UnsupportedWireVersion => "unsupported event wire version",
Self::UnknownEventRole => "unknown event role",
Self::FirstStoryIsNotEventInstance => "first-story detection is not an event instance",
Self::UnknownFirstStoryLabel => "unknown first-story label",
};
formatter.write_str(message)
}
Expand Down Expand Up @@ -65,6 +71,14 @@ mod tests {
"unsupported event wire version",
),
(EventError::UnknownEventRole, "unknown event role"),
(
EventError::FirstStoryIsNotEventInstance,
"first-story detection is not an event instance",
),
(
EventError::UnknownFirstStoryLabel,
"unknown first-story label",
Comment on lines +133 to +135

Copy link
Copy Markdown

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 UnknownFirstStoryLabel test case is missing its closing paren and the reopening paren for the next case, so it fuses with EventTrackIsNotEventInstance into a single four-element tuple. The two variants are no longer asserted separately, and the array of two-element tuples no longer type-checks.

Suggested change
(
EventError::UnknownFirstStoryLabel,
"unknown first-story label",
(
EventError::UnknownFirstStoryLabel,
"unknown first-story label",
),
(
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

),
] {
assert_eq!(error.to_string(), message);
}
Expand Down
175 changes: 175 additions & 0 deletions crates/event_core/src/first_story.rs
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))
}
Comment thread
seonghobae marked this conversation as resolved.
Comment on lines +120 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 InvalidWirePayload when a class is absent, while the pre-existing first_story_detection_rates returns 0.0 for the same case (crates/event_core/src/intelligence.rs:114-136). Two coexisting APIs for the same concept with opposite edge-case semantics can mislead consumers.

Open in Devin Review

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
);
}
}
14 changes: 13 additions & 1 deletion crates/event_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
//!
//! TEPP separates **fallible event mentions** grounded in evidence from
//! **versioned event instances** used for temporal state, multilevel membership,
//! and scientific estimation. Mentions never silently become instances.
//! and scientific estimation. Mentions and first-story detections never
//! silently become instances.

mod confidence;
mod error;
mod first_story;
mod identifier;
mod instance;
mod mention;
Expand All @@ -18,6 +20,16 @@ mod role;
pub use confidence::EventConfidence;
/// 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.
Expand Down
137 changes: 137 additions & 0 deletions crates/event_core/tests/first_story_contract.rs
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
);
}
Loading
Loading