use std::collections::HashMap;
use super::types::{CritSeverity, CritiqueEvent, OrphanFinding, Significance, Staleness};
use crate::timeline::Precision;
pub const DEFAULT_STALENESS_DAYS: i64 = 60;
#[derive(Debug, Clone, Default)]
pub struct ScopeContext {
pub track_linked_counts: HashMap<String, usize>,
pub staleness_threshold_days: i64,
}
impl ScopeContext {
pub fn from_events(events: &[CritiqueEvent], staleness_threshold_days: i64) -> Self {
let mut track_linked_counts: HashMap<String, usize> = HashMap::new();
for e in events {
if !e.is_orphan {
*track_linked_counts.entry(e.track.clone()).or_insert(0) += 1;
}
}
ScopeContext { track_linked_counts, staleness_threshold_days }
}
}
fn precision_is_concrete(p: Precision) -> bool {
matches!(p, Precision::Day | Precision::Hour | Precision::Tick)
}
fn precision_is_vague(p: Precision) -> bool {
matches!(p, Precision::Season | Precision::Year)
}
fn title_word_count(title: &str) -> usize {
title.split_whitespace().filter(|w| !w.is_empty()).count()
}
pub fn significance(event: &CritiqueEvent, ctx: &ScopeContext) -> Significance {
let mut score = 0i32;
if precision_is_concrete(event.precision) {
score += 2;
} else if !precision_is_vague(event.precision) {
score += 1; }
let words = title_word_count(&event.title);
if words >= 4 {
score += 2;
} else if words >= 2 {
score += 1;
}
let track_active = ctx
.track_linked_counts
.get(&event.track)
.copied()
.unwrap_or(0)
>= 3;
if track_active {
score += 1;
}
if score >= 4 {
Significance::High
} else if score >= 2 {
Significance::Moderate
} else {
Significance::Low
}
}
pub fn staleness(event: &CritiqueEvent, threshold_days: i64) -> Staleness {
match event.age_days {
Some(age) if age >= threshold_days => Staleness::Old,
_ => Staleness::Recent,
}
}
pub fn compute_severity(sig: Significance, stale: Staleness) -> CritSeverity {
match (sig, stale) {
(Significance::High, _) => CritSeverity::Contradiction,
(Significance::Moderate, Staleness::Old) => CritSeverity::Warning,
(Significance::Moderate, Staleness::Recent) => CritSeverity::Info,
(Significance::Low, _) => CritSeverity::Info,
}
}
fn rank(sig: Significance) -> u8 {
match sig {
Significance::Low => 0,
Significance::Moderate => 1,
Significance::High => 2,
}
}
pub fn detect(
events: &[CritiqueEvent],
ctx: &ScopeContext,
min_significance: Significance,
) -> Vec<OrphanFinding> {
let threshold = if ctx.staleness_threshold_days > 0 {
ctx.staleness_threshold_days
} else {
DEFAULT_STALENESS_DAYS
};
let mut out = Vec::new();
for e in events {
if !e.is_orphan {
continue;
}
let sig = significance(e, ctx);
if rank(sig) < rank(min_significance) {
continue;
}
let stale = staleness(e, threshold);
let severity = compute_severity(sig, stale);
let reasons = super::lang::orphan_reasons(sig, stale, e.age_days, super::lang::Lang::En);
out.push(OrphanFinding {
event_id: e.id,
title: e.title.clone(),
track: e.track.clone(),
start_ticks: e.start_ticks,
precision: e.precision,
significance: sig,
staleness: stale,
age_days: e.age_days,
severity,
reasons,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
fn orphan(title: &str, precision: Precision, age_days: Option<i64>) -> CritiqueEvent {
CritiqueEvent {
id: Uuid::new_v4(),
title: title.into(),
start_ticks: 0,
end_ticks: None,
precision,
track: "main".into(),
is_orphan: true,
linked_paragraph_count: 0,
characters: vec![],
places: vec![],
age_days,
}
}
#[test]
fn linked_events_are_never_orphans() {
let mut e = orphan("Linked", Precision::Day, None);
e.is_orphan = false;
let ctx = ScopeContext::from_events(&[e.clone()], DEFAULT_STALENESS_DAYS);
assert!(detect(&[e], &ctx, Significance::Low).is_empty());
}
#[test]
fn high_significance_old_orphan_is_contradiction() {
let e = orphan("The Coronation of Hadrin III", Precision::Day, Some(92));
let ctx = ScopeContext::from_events(&[e.clone()], DEFAULT_STALENESS_DAYS);
let f = detect(&[e], &ctx, Significance::Low);
assert_eq!(f.len(), 1);
assert_eq!(f[0].significance, Significance::High);
assert_eq!(f[0].staleness, Staleness::Old);
assert_eq!(f[0].severity, CritSeverity::Contradiction);
assert!(f[0].reasons.iter().any(|r| r.contains("92 days")));
}
#[test]
fn low_significance_recent_stub_is_info() {
let e = orphan("Stub", Precision::Year, Some(3));
let ctx = ScopeContext::from_events(&[e.clone()], DEFAULT_STALENESS_DAYS);
let f = detect(&[e], &ctx, Significance::Low);
assert_eq!(f.len(), 1);
assert_eq!(f[0].significance, Significance::Low);
assert_eq!(f[0].severity, CritSeverity::Info);
}
#[test]
fn min_significance_filters_low_orphans() {
let low = orphan("Stub", Precision::Year, None);
let ctx = ScopeContext::from_events(&[low.clone()], DEFAULT_STALENESS_DAYS);
assert!(detect(&[low], &ctx, Significance::Moderate).is_empty());
}
#[test]
fn active_track_lifts_significance() {
let mut events = vec![
orphan("a", Precision::Month, None),
orphan("b", Precision::Month, None),
orphan("c", Precision::Month, None),
];
for e in &mut events {
e.is_orphan = false; }
let mut orph = orphan("Lost map", Precision::Month, None);
orph.is_orphan = true;
events.push(orph.clone());
let ctx = ScopeContext::from_events(&events, DEFAULT_STALENESS_DAYS);
assert!(ctx.track_linked_counts.get("main").copied().unwrap_or(0) >= 3);
assert_eq!(significance(&orph, &ctx), Significance::Moderate);
let mut quiet = orph.clone();
quiet.track = "aside".into();
assert_eq!(significance(&quiet, &ctx), Significance::Moderate);
}
}