Packages
rbt_weave
0.2.64
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
crates/weave-content/src/staleness.rs
//! Structural staleness analysis for OSINT case files (ADR-021 Layer 1).
//!
//! Analyzes parsed case data offline (no network) to detect cases whose
//! `status` field may no longer reflect reality based on heuristics like
//! event age and status-event mismatches.
use crate::entity::{Entity, FieldValue, Label};
use crate::parser::ParsedCase;
/// Severity of a staleness finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Error,
Warning,
Info,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Error => write!(f, "error"),
Self::Warning => write!(f, "warning"),
Self::Info => write!(f, "info"),
}
}
}
/// A single staleness finding for a case file.
#[derive(Debug)]
pub struct Finding {
pub severity: Severity,
pub message: String,
}
/// Configurable thresholds for staleness detection.
#[derive(Debug, Clone)]
pub struct Thresholds {
pub investigation_months: u32,
pub trial_months: u32,
pub appeal_months: u32,
}
impl Default for Thresholds {
fn default() -> Self {
Self {
investigation_months: 6,
trial_months: 12,
appeal_months: 12,
}
}
}
/// Check a single case for staleness issues.
///
/// `today` is passed explicitly for testability.
pub fn check_case(
case: &ParsedCase,
entities: &[Entity],
thresholds: &Thresholds,
today: (i32, u32, u32),
) -> Vec<Finding> {
let mut findings = Vec::new();
let Some(status) = case.status.as_deref() else {
return findings;
};
let events: Vec<&Entity> = entities
.iter()
.filter(|e| e.label == Label::Event)
.collect();
// Rule: No events
if events.is_empty() {
findings.push(Finding {
severity: Severity::Error,
message: format!("status is '{status}' but case has no events"),
});
return findings;
}
let event_types = collect_event_types(&events);
let latest_date = find_latest_date(&events);
check_age_rules(status, latest_date, thresholds, today, &mut findings);
check_mismatch_rules(status, &event_types, &mut findings);
findings
}
/// Check age-based staleness rules.
fn check_age_rules(
status: &str,
latest_date: Option<(i32, u32, u32)>,
thresholds: &Thresholds,
today: (i32, u32, u32),
findings: &mut Vec<Finding>,
) {
let Some((y, m, d)) = latest_date else {
return;
};
let months_ago = months_between((y, m, d), today);
let (threshold, applies) = match status {
"under_investigation" => (thresholds.investigation_months, true),
"trial" | "open" => (thresholds.trial_months, true),
"appeal" => (thresholds.appeal_months, true),
_ => (0, false),
};
if applies && months_ago > threshold {
findings.push(Finding {
severity: Severity::Warning,
message: format!(
"status is '{status}' and latest event is {months_ago} months ago \
(threshold: {threshold} months)"
),
});
}
}
/// Check status-event mismatch rules.
fn check_mismatch_rules(
status: &str,
event_types: &std::collections::HashSet<String>,
findings: &mut Vec<Finding>,
) {
// Missing verdict (trial without verdict/sentencing)
if status == "trial" && !has_any(event_types, VERDICT_TYPES) {
findings.push(Finding {
severity: Severity::Info,
message: "status is 'trial' but no verdict/sentencing/conviction/acquittal event found"
.to_string(),
});
}
if status == "convicted" && !has_any(event_types, &["verdict", "sentencing", "conviction"]) {
findings.push(Finding {
severity: Severity::Warning,
message: "status is 'convicted' but no verdict/sentencing/conviction event found"
.to_string(),
});
}
if status == "acquitted" && !has_any(event_types, &["verdict", "acquittal"]) {
findings.push(Finding {
severity: Severity::Warning,
message: "status is 'acquitted' but no verdict/acquittal event found".to_string(),
});
}
if status == "pardoned"
&& !has_any(
event_types,
&["verdict", "sentencing", "conviction", "pardon"],
)
{
findings.push(Finding {
severity: Severity::Warning,
message: "status is 'pardoned' but no verdict/sentencing/conviction/pardon event found"
.to_string(),
});
}
}
/// Event types that indicate a verdict or resolution.
const VERDICT_TYPES: &[&str] = &["verdict", "sentencing", "conviction", "acquittal"];
/// Check if the set contains any of the given values.
fn has_any(set: &std::collections::HashSet<String>, values: &[&str]) -> bool {
values.iter().any(|v| set.contains(*v))
}
/// Collect all `event_type` values from events.
fn collect_event_types(events: &[&Entity]) -> std::collections::HashSet<String> {
let mut types = std::collections::HashSet::new();
for e in events {
if let Some((_, FieldValue::Single(val))) = e.fields.iter().find(|(k, _)| k == "event_type")
{
// Normalize: strip "custom:" prefix if present
let normalized = val
.strip_prefix("custom:")
.unwrap_or(val)
.to_lowercase()
.replace(' ', "_");
types.insert(normalized);
}
}
types
}
/// Find the latest `occurred_at` date among events.
/// Returns `(year, month, day)`. Partial dates use end-of-period defaults.
fn find_latest_date(events: &[&Entity]) -> Option<(i32, u32, u32)> {
let mut latest: Option<(i32, u32, u32)> = None;
for e in events {
if let Some((_, FieldValue::Single(val))) =
e.fields.iter().find(|(k, _)| k == "occurred_at")
&& let Some(parsed) = parse_date(val)
{
latest = Some(match latest {
None => parsed,
Some(prev) => {
if date_cmp(parsed, prev) == std::cmp::Ordering::Greater {
parsed
} else {
prev
}
}
});
}
}
latest
}
/// Parse a date string in YYYY, YYYY-MM, or YYYY-MM-DD format.
fn parse_date(s: &str) -> Option<(i32, u32, u32)> {
let parts: Vec<&str> = s.split('-').collect();
match parts.len() {
1 => {
let y = parts[0].parse::<i32>().ok()?;
Some((y, 12, 31)) // year-only: assume end of year for staleness
}
2 => {
let y = parts[0].parse::<i32>().ok()?;
let m = parts[1].parse::<u32>().ok()?;
if !(1..=12).contains(&m) {
return None;
}
Some((y, m, 28)) // month-only: assume end of month (conservative)
}
3 => {
let y = parts[0].parse::<i32>().ok()?;
let m = parts[1].parse::<u32>().ok()?;
let d = parts[2].parse::<u32>().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some((y, m, d))
}
_ => None,
}
}
/// Compare two date tuples.
fn date_cmp(a: (i32, u32, u32), b: (i32, u32, u32)) -> std::cmp::Ordering {
a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))
}
/// Calculate approximate months between two dates.
fn months_between(from: (i32, u32, u32), to: (i32, u32, u32)) -> u32 {
let year_diff = to.0 - from.0;
let month_diff = i64::from(to.1) - i64::from(from.1);
let total = i64::from(year_diff) * 12 + month_diff;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
if total < 0 { 0 } else { total as u32 }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_event(name: &str, event_type: &str, occurred_at: &str) -> Entity {
Entity {
name: name.to_string(),
label: Label::Event,
fields: vec![
(
"event_type".to_string(),
FieldValue::Single(event_type.to_string()),
),
(
"occurred_at".to_string(),
FieldValue::Single(occurred_at.to_string()),
),
],
id: Some("01TEST00000000000000000000".to_string()),
line: 1,
tags: Vec::new(),
slug: None,
}
}
fn make_case(status: &str) -> ParsedCase {
ParsedCase {
id: Some("01TEST00000000000000000000".to_string()),
sources: Vec::new(),
title: "Test Case".to_string(),
summary: "Test summary.".to_string(),
sections: Vec::new(),
case_type: Some("corruption".to_string()),
status: Some(status.to_string()),
amounts: None,
tags: Vec::new(),
tagline: None,
related_cases: Vec::new(),
involved: Vec::new(),
}
}
fn today() -> (i32, u32, u32) {
(2026, 4, 21)
}
#[test]
fn no_events_is_error() {
let case = make_case("convicted");
let findings = check_case(&case, &[], &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Error);
assert!(findings[0].message.contains("no events"));
}
#[test]
fn investigation_stale() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2025-06-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("under_investigation"));
assert!(findings[0].message.contains("10 months ago"));
}
#[test]
fn investigation_fresh() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2026-02-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn trial_stale() {
let case = make_case("trial");
let events = vec![make_event("Indictment", "indictment", "2024-12-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
// Should have warning (stale) + info (missing verdict)
assert!(findings.iter().any(|f| f.severity == Severity::Warning));
assert!(findings.iter().any(|f| f.severity == Severity::Info));
}
#[test]
fn trial_with_verdict_not_missing() {
let case = make_case("trial");
let events = vec![
make_event("Indictment", "indictment", "2026-03-01"),
make_event("Verdict", "conviction", "2026-04-01"),
];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn convicted_without_verdict() {
let case = make_case("convicted");
let events = vec![make_event("Arrest", "arrest", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warning);
assert!(findings[0].message.contains("convicted"));
}
#[test]
fn convicted_with_sentencing() {
let case = make_case("convicted");
let events = vec![make_event("Sentencing", "sentencing", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn acquitted_without_verdict() {
let case = make_case("acquitted");
let events = vec![make_event("Trial", "trial", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("acquitted"));
}
#[test]
fn pardoned_without_conviction() {
let case = make_case("pardoned");
let events = vec![make_event("Arrest", "arrest", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("pardoned"));
}
#[test]
fn pardoned_with_pardon_event() {
let case = make_case("pardoned");
let events = vec![make_event("Pardon", "pardon", "2026-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn custom_thresholds() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2026-01-01")];
let thresholds = Thresholds {
investigation_months: 2,
..Thresholds::default()
};
let findings = check_case(&case, &events, &thresholds, today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("3 months ago"));
}
#[test]
fn no_status_no_findings() {
let mut case = make_case("convicted");
case.status = None;
let events = vec![make_event("Arrest", "arrest", "2020-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty());
}
#[test]
fn partial_date_year_only() {
let case = make_case("under_investigation");
let events = vec![make_event("Raid", "raid", "2025")];
// 2025 -> (2025, 12, 31), which is ~4 months ago from 2026-04-21
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert!(findings.is_empty()); // 4 months < 6 month threshold
}
#[test]
fn open_case_stale() {
let case = make_case("open");
let events = vec![make_event("Filing", "investigation_opened", "2024-01-01")];
let findings = check_case(&case, &events, &Thresholds::default(), today());
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("open"));
}
#[test]
fn months_between_same_date() {
assert_eq!(months_between((2026, 4, 21), (2026, 4, 21)), 0);
}
#[test]
fn months_between_year_diff() {
assert_eq!(months_between((2024, 4, 1), (2026, 4, 21)), 24);
}
#[test]
fn parse_date_formats() {
assert_eq!(parse_date("2025"), Some((2025, 12, 31)));
assert_eq!(parse_date("2025-06"), Some((2025, 6, 28)));
assert_eq!(parse_date("2025-06-15"), Some((2025, 6, 15)));
assert_eq!(parse_date("invalid"), None);
assert_eq!(parse_date("2025-13"), None);
}
}