use rusqlite::params;
use super::types::*;
use super::SqliteStore;
impl SqliteStore {
pub fn village_stats(
&self,
village_id: &str,
after: Option<&str>,
before: Option<&str>,
) -> anyhow::Result<VillageStats> {
use std::collections::HashMap;
let mut temporal_sql = String::new();
let mut string_params: Vec<String> = vec![village_id.to_string()];
let mut idx = 2;
if let Some(a) = after {
temporal_sql.push_str(&format!(" AND e.ts >= ?{idx}"));
string_params.push(a.to_string());
idx += 1;
}
if let Some(b) = before {
temporal_sql.push_str(&format!(" AND e.ts <= ?{idx}"));
string_params.push(b.to_string());
let _ = idx;
}
let refs = || -> Vec<&dyn rusqlite::types::ToSql> {
string_params
.iter()
.map(|s| s as &dyn rusqlite::types::ToSql)
.collect()
};
let total_sql = format!(
"SELECT COUNT(*) FROM decisions d JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1{temporal_sql}"
);
let total: usize = self
.conn
.query_row(&total_sql, refs().as_slice(), |row| row.get::<_, usize>(0))?;
let status_sql = format!(
"SELECT d.status, COUNT(*) FROM decisions d JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1{temporal_sql} GROUP BY d.status"
);
let mut status_stmt = self.conn.prepare(&status_sql)?;
let status_rows = status_stmt.query_map(refs().as_slice(), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
})?;
let by_status: HashMap<String, usize> = status_rows.filter_map(|r| r.ok()).collect();
let auth_sql = format!(
"SELECT d.authority, COUNT(*) FROM decisions d JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1{temporal_sql} GROUP BY d.authority"
);
let mut auth_stmt = self.conn.prepare(&auth_sql)?;
let auth_rows = auth_stmt.query_map(refs().as_slice(), |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
})?;
let by_authority: HashMap<String, usize> = auth_rows.filter_map(|r| r.ok()).collect();
let domain_sql = format!(
"SELECT d.domain, COUNT(*) as cnt FROM decisions d JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1{temporal_sql} GROUP BY d.domain ORDER BY cnt DESC LIMIT 10"
);
let mut domain_stmt = self.conn.prepare(&domain_sql)?;
let domain_rows = domain_stmt.query_map(refs().as_slice(), |row| {
Ok(DomainCount {
domain: row.get(0)?,
count: row.get(1)?,
})
})?;
let top_domains: Vec<DomainCount> = domain_rows.filter_map(|r| r.ok()).collect();
let trend_sql = format!(
"SELECT DATE(e.ts) as day, COUNT(*) FROM decisions d JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1{temporal_sql} GROUP BY day ORDER BY day"
);
let mut trend_stmt = self.conn.prepare(&trend_sql)?;
let trend_rows = trend_stmt.query_map(refs().as_slice(), |row| {
Ok(DayCount {
date: row.get(0)?,
count: row.get(1)?,
})
})?;
let trend: Vec<DayCount> = trend_rows.filter_map(|r| r.ok()).collect();
let superseded_count = by_status.get("superseded").copied().unwrap_or(0);
let rollback_rate = if total > 0 {
superseded_count as f64 / total as f64
} else {
0.0
};
let days = if trend.is_empty() {
1.0
} else {
trend.len() as f64
};
let decisions_per_day = total as f64 / days;
let period = if after.is_some() || before.is_some() {
Some(VillageStatsPeriod {
after: after.map(|s| s.to_string()),
before: before.map(|s| s.to_string()),
})
} else {
None
};
Ok(VillageStats {
village_id: village_id.to_string(),
period,
total_decisions: total,
decisions_per_day,
by_status,
by_authority,
top_domains,
rollback_rate,
trend,
})
}
pub fn detect_village_patterns(
&self,
village_id: &str,
after: &str,
min_occurrences: usize,
) -> anyhow::Result<Vec<DetectedPattern>> {
let mut patterns = Vec::new();
{
let sql = "
SELECT d.key, d.domain, COUNT(*) as cnt,
MIN(e.ts) as first_seen, MAX(e.ts) as last_seen,
GROUP_CONCAT(DATE(e.ts), ',') as dates
FROM decisions d
JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1 AND e.ts >= ?2
GROUP BY d.key, d.domain
HAVING cnt >= ?3
ORDER BY cnt DESC
";
let mut stmt = self.conn.prepare(sql)?;
let rows =
stmt.query_map(params![village_id, after, min_occurrences as i64], |row| {
let key: String = row.get(0)?;
let domain: String = row.get(1)?;
let cnt: usize = row.get(2)?;
let first: String = row.get(3)?;
let last: String = row.get(4)?;
let dates_str: String = row.get::<_, Option<String>>(5)?.unwrap_or_default();
Ok((key, domain, cnt, first, last, dates_str))
})?;
for row in rows {
let (key, domain, cnt, first, last, dates_str) = row?;
let dates: Vec<String> = dates_str
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
patterns.push(DetectedPattern {
pattern_type: PatternType::RecurringDecision,
description: format!("{key} changed {cnt} times in window"),
key,
domain,
authority: None,
occurrences: cnt,
first_seen: first,
last_seen: last,
dates,
trending_up: None,
});
}
}
{
let sql = "
SELECT d.authority, d.key, d.domain, COUNT(*) as cnt,
MIN(e.ts) as first_seen, MAX(e.ts) as last_seen,
GROUP_CONCAT(DATE(e.ts), ',') as dates
FROM decisions d
JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1 AND e.ts >= ?2
AND d.authority != 'system'
GROUP BY d.authority, d.key, d.domain
HAVING cnt >= ?3
ORDER BY cnt DESC
";
let mut stmt = self.conn.prepare(sql)?;
let rows =
stmt.query_map(params![village_id, after, min_occurrences as i64], |row| {
let authority: String = row.get(0)?;
let key: String = row.get(1)?;
let domain: String = row.get(2)?;
let cnt: usize = row.get(3)?;
let first: String = row.get(4)?;
let last: String = row.get(5)?;
let dates_str: String = row.get::<_, Option<String>>(6)?.unwrap_or_default();
Ok((authority, key, domain, cnt, first, last, dates_str))
})?;
for row in rows {
let (authority, key, domain, cnt, first, last, dates_str) = row?;
let dates: Vec<String> = dates_str
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
patterns.push(DetectedPattern {
pattern_type: PatternType::ChiefRepeatedAction,
description: format!("{authority} changed {key} {cnt} times in window"),
key,
domain,
authority: Some(authority),
occurrences: cnt,
first_seen: first,
last_seen: last,
dates,
trending_up: None,
});
}
}
{
let sql = "
SELECT d.key, d.domain, COUNT(*) as cnt,
MIN(e.ts) as first_seen, MAX(e.ts) as last_seen,
GROUP_CONCAT(DATE(e.ts), ',') as dates
FROM decisions d
JOIN events e ON d.event_id = e.event_id
WHERE d.village_id = ?1 AND e.ts >= ?2
AND d.supersedes_id IS NOT NULL
GROUP BY d.key, d.domain
HAVING cnt >= 2
ORDER BY cnt DESC
";
let mut stmt = self.conn.prepare(sql)?;
let rows = stmt.query_map(params![village_id, after], |row| {
let key: String = row.get(0)?;
let domain: String = row.get(1)?;
let cnt: usize = row.get(2)?;
let first: String = row.get(3)?;
let last: String = row.get(4)?;
let dates_str: String = row.get::<_, Option<String>>(5)?.unwrap_or_default();
Ok((key, domain, cnt, first, last, dates_str))
})?;
for row in rows {
let (key, domain, cnt, first, last, dates_str) = row?;
let dates: Vec<String> = dates_str
.split(',')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
let trending_up = detect_trend_direction(&dates, after);
patterns.push(DetectedPattern {
pattern_type: PatternType::RollbackTrend,
description: format!("{key} rolled back {cnt} times in window"),
key,
domain,
authority: None,
occurrences: cnt,
first_seen: first,
last_seen: last,
dates,
trending_up: Some(trending_up),
});
}
}
Ok(patterns)
}
}
pub(super) fn detect_trend_direction(dates: &[String], _after: &str) -> bool {
if dates.len() < 2 {
return false;
}
let mut sorted: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();
sorted.sort();
if sorted.first() == sorted.last() {
return false;
}
let mid = sorted.len() / 2;
let mid_date = sorted[mid]; let first_half = sorted.iter().filter(|&&d| d < mid_date).count();
let second_half = sorted.iter().filter(|&&d| d >= mid_date).count();
second_half > first_half
}