use std::{collections::BTreeMap, fmt::Write};
use anyhow::Result;
use jevia_core::{DecisionSource, Outcome, OutcomeSource, RouteRecord};
use serde::Serialize;
use crate::storage::Storage;
#[derive(Default, Serialize)]
struct Outcomes {
successes: usize,
failures: usize,
}
impl Outcomes {
fn add(&mut self, outcome: Outcome) {
match outcome {
Outcome::Success => self.successes += 1,
Outcome::Failure => self.failures += 1,
Outcome::Unknown => unreachable!("unknown outcomes are counted separately"),
}
}
fn total(&self) -> usize {
self.successes + self.failures
}
}
#[derive(Default, Serialize)]
struct Counts {
records: usize,
cache_hits: usize,
learning_evidence: usize,
verified: Outcomes,
manual: Outcomes,
process_exit: Outcomes,
unattributed: Outcomes,
active: usize,
unknown: usize,
}
impl Counts {
fn add(&mut self, record: &RouteRecord) {
self.records += 1;
self.cache_hits += usize::from(record.decision.source == DecisionSource::Cache);
self.learning_evidence += usize::from(record.is_learning_evidence());
if record
.lifecycle
.as_ref()
.is_some_and(|l| l.state.is_active())
{
self.active += 1;
} else if record.outcome == Outcome::Unknown {
self.unknown += 1;
} else {
match record.outcome_evidence.as_ref().map(|e| e.source) {
Some(OutcomeSource::Verification) => &mut self.verified,
Some(OutcomeSource::Manual) => &mut self.manual,
Some(OutcomeSource::ProcessExit) => &mut self.process_exit,
None => &mut self.unattributed,
}
.add(record.outcome);
}
}
fn summarize(self) -> Metrics {
Metrics {
verified_success_rate: ratio(self.verified.successes, self.verified.total()),
cache_hit_rate: ratio(self.cache_hits, self.records),
counts: self,
}
}
}
#[derive(Serialize)]
struct Metrics {
#[serde(flatten)]
counts: Counts,
verified_success_rate: Option<f64>,
cache_hit_rate: Option<f64>,
}
#[derive(Serialize)]
struct Window {
limit: usize,
order: &'static str,
has_older_records: bool,
}
#[derive(Serialize)]
pub struct Report {
schema_version: u32,
storage: &'static str,
window: Window,
totals: Metrics,
tiers: BTreeMap<String, Metrics>,
}
pub async fn collect(storage: &Storage, limit: usize) -> Result<Report> {
let records = storage.recent(limit + 1, false).await?;
Ok(Report::new(storage.name(), limit, &records))
}
impl Report {
fn new(storage: &'static str, limit: usize, records: &[RouteRecord]) -> Self {
let mut totals = Counts::default();
let mut tiers = BTreeMap::<String, Counts>::new();
for record in &records[records.len().saturating_sub(limit)..] {
totals.add(record);
tiers
.entry(record.decision.tier.clone())
.or_default()
.add(record);
}
Self {
schema_version: 1,
storage,
window: Window {
limit,
order: "append",
has_older_records: records.len() > limit,
},
totals: totals.summarize(),
tiers: tiers
.into_iter()
.map(|(tier, counts)| (tier, counts.summarize()))
.collect(),
}
}
pub fn render(&self) -> String {
let mut output = String::new();
let counts = &self.totals.counts;
writeln!(output, "Routing stats ({})", self.storage).unwrap();
writeln!(
output,
"Window: {} records, latest {} in append order; {}",
counts.records,
self.window.limit,
if self.window.has_older_records {
"older records excluded"
} else {
"all retained records included"
}
)
.unwrap();
if counts.records == 0 {
writeln!(output, "No runs recorded. Rates: n/a (no observations).").unwrap();
return output;
}
writeln!(
output,
"Cache hits: {}/{} ({}) | Learning evidence: {}",
counts.cache_hits,
counts.records,
percent(self.totals.cache_hit_rate),
counts.learning_evidence
)
.unwrap();
writeln!(
output,
"Verified success: {}/{} ({})",
counts.verified.successes,
counts.verified.total(),
percent(self.totals.verified_success_rate)
)
.unwrap();
writeln!(
output,
"\n{:<16} {:>6} {:>7} {:>15} {:>9} {:>13} {:>9}",
"Tier", "Runs", "Cached", "Verified ok/fail", "Success", "Manual ok/fail", "Evidence"
)
.unwrap();
for (tier, metrics) in &self.tiers {
let c = &metrics.counts;
writeln!(
output,
"{:<16} {:>6} {:>7} {:>15} {:>9} {:>13} {:>9}",
tier.escape_debug().to_string(),
c.records,
c.cache_hits,
format!("{}/{}", c.verified.successes, c.verified.failures),
percent(metrics.verified_success_rate),
format!("{}/{}", c.manual.successes, c.manual.failures),
c.learning_evidence
)
.unwrap();
}
writeln!(output, "\nOther outcomes (excluded from verified rate): process-exit-only {} ok/{} fail; unattributed {} ok/{} fail; active {}; unknown {}.",
counts.process_exit.successes, counts.process_exit.failures, counts.unattributed.successes, counts.unattributed.failures, counts.active, counts.unknown).unwrap();
writeln!(
output,
"Manual feedback is separate. Latest outcome per run; n/a means no verified outcomes."
)
.unwrap();
writeln!(
output,
"Observed results only, not a model ranking or proof of routing improvement."
)
.unwrap();
output
}
}
fn ratio(numerator: usize, denominator: usize) -> Option<f64> {
(denominator != 0).then(|| numerator as f64 / denominator as f64)
}
fn percent(value: Option<f64>) -> String {
value
.map(|n| format!("{:.1}%", n * 100.0))
.unwrap_or_else(|| "n/a".into())
}
#[cfg(test)]
mod tests {
use super::*;
use jevia_core::{OutcomeEvidence, RouteDecision, RunState};
fn record(outcome: Outcome, source: Option<OutcomeSource>) -> RouteRecord {
let mut record = RouteRecord::new(
RouteDecision {
run_id: "private-run-id".into(),
tier: "fast".into(),
suggested_tier: "strong".into(),
confidence: 0.9,
probabilities: BTreeMap::new(),
fallback_applied: true,
jev_model: "private-model".into(),
created_at_ms: 1,
source: DecisionSource::Live,
},
Some("private-task".into()),
);
record.outcome = outcome;
record.outcome_evidence = source.map(|source| OutcomeEvidence {
source,
recorded_at_ms: 1,
});
record
}
#[test]
fn outcome_buckets_partition_records_and_exclude_active_or_unknown_evidence() {
let mut records = vec![];
for source in [
Some(OutcomeSource::Verification),
Some(OutcomeSource::Manual),
Some(OutcomeSource::ProcessExit),
None,
] {
for outcome in [Outcome::Success, Outcome::Failure, Outcome::Unknown] {
for state in [RunState::Completed, RunState::Running, RunState::Verifying] {
let mut record = record(outcome, source);
record.lifecycle.as_mut().unwrap().state = state;
records.push(record);
}
}
}
let report = Report::new("jsonl", 1000, &records);
let c = &report.totals.counts;
assert_eq!(c.records, 36);
assert_eq!(c.active, 24);
assert_eq!(c.unknown, 4);
assert_eq!(c.verified.successes, 1);
assert_eq!(c.verified.failures, 1);
assert_eq!(c.manual.total(), 2);
assert_eq!(c.process_exit.total(), 2);
assert_eq!(c.unattributed.total(), 2);
assert_eq!(c.learning_evidence, 4);
assert_eq!(
c.records,
c.active
+ c.unknown
+ c.verified.total()
+ c.manual.total()
+ c.process_exit.total()
+ c.unattributed.total()
);
assert_eq!(report.totals.verified_success_rate, Some(0.5));
assert_eq!(report.tiers.len(), 1);
assert_eq!(report.tiers["fast"].counts.records, 36);
}
#[test]
fn no_observations_are_null_not_zero_and_only_current_outcomes_count() {
let empty = Report::new("sqlite", 10, &[]);
assert_eq!(empty.totals.cache_hit_rate, None);
assert_eq!(empty.totals.verified_success_rate, None);
assert!(empty.render().contains("No runs recorded"));
assert!(serde_json::to_value(&empty).unwrap()["totals"]["verified_success_rate"].is_null());
let mut manual = record(Outcome::Failure, Some(OutcomeSource::Manual));
manual.feedback = vec![
jevia_core::FeedbackEvent {
previous_outcome: Outcome::Success,
previous_source: Some(OutcomeSource::Verification),
outcome: Outcome::Failure,
recorded_at_ms: 2,
reason: Some("private-reason".into()),
};
5
];
let report = Report::new("jsonl", 10, &[manual]);
assert_eq!(report.totals.counts.records, 1);
assert_eq!(report.totals.counts.manual.failures, 1);
assert_eq!(report.totals.counts.learning_evidence, 1);
assert_eq!(report.totals.verified_success_rate, None);
assert_eq!(report.totals.cache_hit_rate, Some(0.0));
let serialized = serde_json::to_string(&report).unwrap();
assert!(!serialized.contains("private-"));
assert!(!report.render().contains("private-"));
}
#[test]
fn bounded_window_uses_append_order_and_distinguishes_zero_from_no_data() {
let mut old = record(Outcome::Success, Some(OutcomeSource::Verification));
old.decision.created_at_ms = 9999;
old.decision.tier = "old-tier".into();
let mut latest = record(Outcome::Failure, Some(OutcomeSource::Verification));
latest.decision.source = DecisionSource::Cache;
let report = Report::new("postgres", 1, &[old, latest]);
assert!(report.window.has_older_records);
assert_eq!(report.totals.counts.records, 1);
assert_eq!(report.totals.verified_success_rate, Some(0.0));
assert_eq!(report.totals.cache_hit_rate, Some(1.0));
assert!(!report.tiers.contains_key("old-tier"));
assert!(report.render().contains("0.0%"));
}
#[test]
fn imported_tier_names_cannot_emit_terminal_controls() {
let mut record = record(Outcome::Unknown, None);
record.decision.tier = "tier\n\u{1b}[2J".into();
let report = Report::new("jsonl", 1, &[record]);
let output = report.render();
assert!(output.contains("tier\\n\\u{1b}[2J"));
assert!(!output.contains('\u{1b}'));
}
}