diff --git a/Cargo.lock b/Cargo.lock index d6979e2..d9bd8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -298,6 +304,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "prettyplease" version = "0.2.37" @@ -411,6 +423,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "2.0.117" @@ -479,8 +500,10 @@ version = "0.1.0" dependencies = [ "clap", "dirs", + "owo-colors", "serde", "serde_json", + "supports-color", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 4874bb9..5dbe1ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" +owo-colors = "4" +supports-color = "3" [dev-dependencies] tempfile = "3" diff --git a/src/collect.rs b/src/collect.rs index adc214c..ea97721 100644 --- a/src/collect.rs +++ b/src/collect.rs @@ -1,9 +1,8 @@ use std::collections::HashMap; -use crate::{analyze, parser, paths, signals, state}; +use owo_colors::OwoColorize; -const GREEN: &str = "\x1b[32m"; -const RESET: &str = "\x1b[0m"; +use crate::{analyze, parser, paths, signals, state}; pub fn run(trigger: &str) -> Result<(), String> { let reflections_content = parser::read_or_empty(&paths::reflections_file()?); @@ -55,7 +54,7 @@ pub fn run(trigger: &str) -> Result<(), String> { state::save_analysis(&analysis)?; // Print summary - println!("{GREEN}✓{RESET} Collected signal vector ({trigger})"); + println!("{} Collected signal vector ({trigger})", "✓".green()); print_signal(" vocabulary_diversity", sigs.vocabulary_diversity); print_signal(" question_generation", sigs.question_generation); print_signal(" thought_lifecycle", sigs.thought_lifecycle); diff --git a/src/init.rs b/src/init.rs index 05f3b27..854412d 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,6 +1,8 @@ use std::fs; use std::path::PathBuf; +use owo_colors::OwoColorize; + use crate::paths; const PROTOCOL_TEMPLATE: &str = include_str!("../templates/vigil-echo.md"); @@ -8,12 +10,6 @@ const PROTOCOL_TEMPLATE: &str = include_str!("../templates/vigil-echo.md"); const PULSE_COMMAND: &str = "vigil-echo pulse"; const COLLECT_COMMAND: &str = "vigil-echo collect --trigger session-end"; -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RED: &str = "\x1b[31m"; -const BOLD: &str = "\x1b[1m"; -const RESET: &str = "\x1b[0m"; - enum Status { Created, Exists, @@ -22,9 +18,9 @@ enum Status { fn print_status(status: Status, msg: &str) { match status { - Status::Created => println!(" {GREEN}✓{RESET} {msg}"), - Status::Exists => println!(" {YELLOW}~{RESET} {msg}"), - Status::Error => println!(" {RED}✗{RESET} {msg}"), + Status::Created => println!(" {} {msg}", "✓".green()), + Status::Exists => println!(" {} {msg}", "~".yellow()), + Status::Error => println!(" {} {msg}", "✗".red()), } } @@ -201,7 +197,10 @@ pub fn run() -> Result<(), String> { ); } - println!("\n{BOLD}vigil-echo{RESET} — initializing metacognitive monitoring\n"); + println!( + "\n{} — initializing metacognitive monitoring\n", + "vigil-echo".bold() + ); // Create directories let rules_dir = paths::rules_dir()?; @@ -229,7 +228,7 @@ pub fn run() -> Result<(), String> { // Summary println!( - "\n{BOLD}Setup complete.{RESET} Metacognitive monitoring is ready.\n\n\ + "\n{} Metacognitive monitoring is ready.\n\n\ \x20 Signals tracked (Phase 1):\n\ \x20 vocabulary_diversity — Lexical variety in reflections\n\ \x20 question_generation — Active curiosity level\n\ @@ -241,7 +240,8 @@ pub fn run() -> Result<(), String> { \x20 Commands:\n\ \x20 vigil-echo status — Cognitive health dashboard\n\ \x20 vigil-echo collect — Manual signal collection\n\ - \x20 vigil-echo pulse — Manual pulse injection\n" + \x20 vigil-echo pulse — Manual pulse injection\n", + "Setup complete.".bold() ); Ok(()) diff --git a/src/main.rs b/src/main.rs index 68fb391..75fa6d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,9 +6,11 @@ mod paths; mod pulse; mod signals; mod state; +mod stats; mod status; use clap::{Parser, Subcommand}; +use owo_colors::OwoColorize; #[derive(Parser)] #[command( @@ -40,7 +42,11 @@ enum Commands { /// Inject cognitive health assessment at session start Pulse, /// Cognitive health dashboard - Status, + Status { + /// Output in JSON format + #[arg(long)] + json: bool, + }, } fn main() { @@ -56,14 +62,14 @@ fn main() { c } Err(e) => { - eprintln!("\x1b[31m✗\x1b[0m {e}"); + eprintln!("{} {e}", "✗".red()); std::process::exit(1); } }; let history = match state::load_signals() { Ok(h) => h, Err(e) => { - eprintln!("\x1b[31m✗\x1b[0m {e}"); + eprintln!("{} {e}", "✗".red()); std::process::exit(1); } }; @@ -77,11 +83,11 @@ fn main() { } } Some(Commands::Pulse) => pulse::run(), - Some(Commands::Status) => status::run(), + Some(Commands::Status { json }) => status::run(json), }; if let Err(e) = result { - eprintln!("\x1b[31m✗\x1b[0m {e}"); + eprintln!("{} {e}", "✗".red()); std::process::exit(1); } } diff --git a/src/pulse.rs b/src/pulse.rs index b96e33f..11ec9cb 100644 --- a/src/pulse.rs +++ b/src/pulse.rs @@ -1,3 +1,5 @@ +use owo_colors::OwoColorize; + use crate::state::{self, AlertLevel, Trend}; pub fn run() -> Result<(), String> { @@ -31,10 +33,10 @@ pub fn run() -> Result<(), String> { // Format output let level_str = match &analysis.alert_level { - AlertLevel::Healthy => "\x1b[32mHEALTHY\x1b[0m", - AlertLevel::Watch => "\x1b[33mWATCH\x1b[0m", - AlertLevel::Concern => "\x1b[31mCONCERN\x1b[0m", - AlertLevel::Alert => "\x1b[1;31mALERT\x1b[0m", + AlertLevel::Healthy => format!("{}", "HEALTHY".green()), + AlertLevel::Watch => format!("{}", "WATCH".yellow()), + AlertLevel::Concern => format!("{}", "CONCERN".red()), + AlertLevel::Alert => format!("{}", "ALERT".red().bold()), }; println!("[VIGIL — Cognitive Health]\n"); @@ -56,9 +58,9 @@ pub fn run() -> Result<(), String> { println!(); for (name, trend) in &analysis.signals { let arrow = match trend.trend { - Trend::Improving => "\x1b[32m↑\x1b[0m", - Trend::Stable => "→", - Trend::Declining => "\x1b[31m↓\x1b[0m", + Trend::Improving => format!("{}", "↑".green()), + Trend::Stable => "→".to_string(), + Trend::Declining => format!("{}", "↓".red()), }; let val = trend .current diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..940b409 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,245 @@ +use crate::state::SignalVector; + +const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + +/// Extract all non-None values for a named signal from history. +pub fn signal_series(history: &[SignalVector], name: &str) -> Vec { + history + .iter() + .filter_map(|sv| match name { + "vocabulary_diversity" => sv.signals.vocabulary_diversity, + "question_generation" => sv.signals.question_generation, + "thought_lifecycle" => sv.signals.thought_lifecycle, + "evidence_grounding" => sv.signals.evidence_grounding, + _ => None, + }) + .collect() +} + +/// Arithmetic mean. +pub fn mean(values: &[f64]) -> Option { + if values.is_empty() { + return None; + } + Some(values.iter().sum::() / values.len() as f64) +} + +/// Population standard deviation. +pub fn std_dev(values: &[f64]) -> Option { + if values.len() < 2 { + return None; + } + let m = mean(values)?; + let variance = values.iter().map(|v| (v - m).powi(2)).sum::() / values.len() as f64; + Some(variance.sqrt()) +} + +/// Percentile rank of `value` within a set of values (0.0–100.0). +pub fn percentile_rank(value: f64, values: &[f64]) -> f64 { + if values.is_empty() { + return 50.0; + } + let below = values.iter().filter(|&&v| v < value).count(); + let equal = values + .iter() + .filter(|&&v| (v - value).abs() < f64::EPSILON) + .count(); + ((below as f64 + equal as f64 * 0.5) / values.len() as f64) * 100.0 +} + +/// Z-score: how many standard deviations `value` is from `mean`. +pub fn z_score(value: f64, mean: f64, std_dev: f64) -> f64 { + (value - mean) / std_dev +} + +/// Detect consecutive same-direction streak at the end of a series. +/// Returns (direction, count) where direction is 1 (up), -1 (down), 0 (flat). +pub fn streak(values: &[f64]) -> (i8, usize) { + if values.len() < 2 { + return (0, 0); + } + let last = values[values.len() - 1]; + let prev = values[values.len() - 2]; + let direction = if last > prev + f64::EPSILON { + 1 + } else if last < prev - f64::EPSILON { + -1 + } else { + 0 + }; + if direction == 0 { + let mut count = 1; + for i in (0..values.len() - 1).rev() { + if (values[i] - values[i + 1]).abs() < f64::EPSILON { + count += 1; + } else { + break; + } + } + return (0, count); + } + let mut count = 1; + for i in (1..values.len() - 1).rev() { + let d = values[i] - values[i - 1]; + let matches = if direction == 1 { + d > f64::EPSILON + } else { + d < -f64::EPSILON + }; + if matches { + count += 1; + } else { + break; + } + } + (direction, count) +} + +/// Generate a sparkline string from a series of values. +/// Maps values to Unicode block elements: ▁▂▃▄▅▆▇█ +pub fn sparkline(values: &[f64], width: usize) -> String { + if values.is_empty() || width == 0 { + return String::new(); + } + let sampled: Vec = if values.len() > width { + (0..width) + .map(|i| { + let idx = i * (values.len() - 1) / (width - 1); + values[idx] + }) + .collect() + } else { + values.to_vec() + }; + + let min = sampled.iter().cloned().fold(f64::INFINITY, f64::min); + let max = sampled.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let range = max - min; + + sampled + .iter() + .map(|&v| { + if range == 0.0 { + BLOCKS[3] + } else { + let normalized = ((v - min) / range * 7.0).round() as usize; + BLOCKS[normalized.min(7)] + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::{SignalVector, Signals}; + use std::collections::HashMap; + + #[test] + fn mean_basic() { + assert_eq!(mean(&[1.0, 2.0, 3.0]), Some(2.0)); + assert_eq!(mean(&[]), None); + } + + #[test] + fn std_dev_basic() { + let sd = std_dev(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]).unwrap(); + assert!((sd - 2.0).abs() < 0.01); + } + + #[test] + fn std_dev_single_value() { + assert_eq!(std_dev(&[5.0]), None); + } + + #[test] + fn percentile_rank_basic() { + let values = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + assert!((percentile_rank(3.0, &values) - 50.0).abs() < 1.0); + assert!(percentile_rank(5.0, &values) > 80.0); + assert!(percentile_rank(1.0, &values) < 20.0); + } + + #[test] + fn z_score_basic() { + assert!((z_score(12.0, 10.0, 2.0) - 1.0).abs() < f64::EPSILON); + assert!((z_score(8.0, 10.0, 2.0) - (-1.0)).abs() < f64::EPSILON); + } + + #[test] + fn sparkline_flat() { + let spark = sparkline(&[5.0, 5.0, 5.0, 5.0], 4); + assert_eq!(spark, "▄▄▄▄"); + } + + #[test] + fn sparkline_ascending() { + let spark = sparkline(&[0.0, 0.5, 1.0], 3); + assert_eq!(spark, "▁▅█"); + } + + #[test] + fn sparkline_empty() { + assert_eq!(sparkline(&[], 10), ""); + } + + #[test] + fn sparkline_single() { + let spark = sparkline(&[3.0], 1); + assert_eq!(spark, "▄"); + } + + #[test] + fn sparkline_subsamples_long_series() { + let values: Vec = (0..100).map(|i| i as f64).collect(); + let spark = sparkline(&values, 10); + assert_eq!(spark.chars().count(), 10); + assert_eq!(spark.chars().next(), Some('▁')); + assert_eq!(spark.chars().last(), Some('█')); + } + + #[test] + fn streak_ascending() { + let (dir, count) = streak(&[1.0, 2.0, 3.0, 4.0]); + assert_eq!(dir, 1); + assert_eq!(count, 3); + } + + #[test] + fn streak_descending() { + let (dir, count) = streak(&[4.0, 3.0, 2.0, 1.0]); + assert_eq!(dir, -1); + assert_eq!(count, 3); + } + + #[test] + fn streak_flat() { + let (dir, count) = streak(&[5.0, 5.0, 5.0, 5.0]); + assert_eq!(dir, 0); + assert_eq!(count, 4); + } + + #[test] + fn streak_too_short() { + let (dir, count) = streak(&[5.0]); + assert_eq!(dir, 0); + assert_eq!(count, 0); + } + + #[test] + fn signal_series_extracts() { + let history = vec![SignalVector { + timestamp: "2026-01-01T00:00:00Z".into(), + trigger: "test".into(), + signals: Signals { + vocabulary_diversity: Some(0.5), + question_generation: Some(3.0), + thought_lifecycle: None, + evidence_grounding: Some(0.8), + }, + document_hashes: HashMap::new(), + }]; + assert_eq!(signal_series(&history, "vocabulary_diversity"), vec![0.5]); + assert!(signal_series(&history, "thought_lifecycle").is_empty()); + } +} diff --git a/src/status.rs b/src/status.rs index ba49514..b74af31 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,115 +1,406 @@ -use crate::state::{self, AlertLevel, Trend}; +use owo_colors::OwoColorize; -const BOLD: &str = "\x1b[1m"; -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RED: &str = "\x1b[31m"; -const DIM: &str = "\x1b[2m"; -const RESET: &str = "\x1b[0m"; +use crate::state::{self, AlertLevel, Analysis, Config, SignalVector, Trend}; +use crate::stats; -pub fn run() -> Result<(), String> { +const SIGNAL_NAMES: [&str; 4] = [ + "vocabulary_diversity", + "question_generation", + "thought_lifecycle", + "evidence_grounding", +]; + +const SPARKLINE_WIDTH: usize = 20; + +pub fn run(json_output: bool) -> Result<(), String> { let config = state::load_config()?; let history = state::load_signals()?; let analysis = state::load_analysis()?; - println!("\n{BOLD}vigil-echo{RESET} — cognitive health dashboard\n"); + if json_output { + return print_json(&config, &history, &analysis); + } - // Signal history - println!(" {BOLD}Signal History{RESET}"); + print_dashboard(&config, &history, &analysis) +} + +fn print_dashboard( + config: &Config, + history: &[SignalVector], + analysis: &Option, +) -> Result<(), String> { + // Header + println!(); + println!(" {} — cognitive health dashboard", "vigil-echo".bold()); + println!(); + + // Status line + print_status_line(analysis, history.len(), config.window_size); + + // Signals with sparklines + println!(); + println!(" {}", "Signals".bold()); if history.is_empty() { println!(" No signals collected yet. Run `vigil-echo collect` after a session."); } else { - println!( - " {} data points (max {})", - history.len(), - config.max_history - ); - if let Some(first) = history.first() { - println!(" First: {}", &first.timestamp[..19]); - } - if let Some(last) = history.last() { - println!(" Latest: {}", &last.timestamp[..19]); - println!(); - println!(" {BOLD}Latest Signals{RESET}"); - print_signal( - " vocabulary_diversity", - last.signals.vocabulary_diversity, - ); - print_signal(" question_generation", last.signals.question_generation); - print_signal(" thought_lifecycle", last.signals.thought_lifecycle); - print_signal(" evidence_grounding", last.signals.evidence_grounding); + for &name in &SIGNAL_NAMES { + print_signal_row(name, history, analysis); } } - // Analysis - println!(); - if let Some(analysis) = analysis { - let level_str = match &analysis.alert_level { - AlertLevel::Healthy => format!("{GREEN}HEALTHY{RESET}"), - AlertLevel::Watch => format!("{YELLOW}WATCH{RESET}"), - AlertLevel::Concern => format!("{RED}CONCERN{RESET}"), - AlertLevel::Alert => format!("{BOLD}{RED}ALERT{RESET}"), - }; - println!(" {BOLD}Analysis{RESET}"); - println!(" Status: {level_str}"); - println!( - " {} improving, {} stable, {} declining", - analysis.improving_count, analysis.stable_count, analysis.declining_count - ); + // Statistics + if history.len() >= 3 { + println!(); + println!(" {}", "Statistics".bold()); + for &name in &SIGNAL_NAMES { + print_stats_row(name, history); + } + } - if !analysis.signals.is_empty() { - println!(); - println!(" {BOLD}Trends{RESET}"); - for (name, trend) in &analysis.signals { - let (arrow, color) = match trend.trend { - Trend::Improving => ("↑", GREEN), - Trend::Stable => ("→", DIM), - Trend::Declining => ("↓", RED), - }; - let val = trend - .current - .map(|v| format!("{:.2}", v)) - .unwrap_or("—".to_string()); - println!( - " {color}{arrow}{RESET} {:<24} {} ({:+.2})", - friendly_name(name), - val, - trend.delta - ); - } + // Anomalies + let anomalies = detect_anomalies(history); + if !anomalies.is_empty() { + println!(); + println!(" {}", "Anomalies".bold()); + for anomaly in &anomalies { + println!(" {} {anomaly}", "*".yellow()); } + } + // Alerts from analysis + if let Some(analysis) = analysis { if !analysis.watch_messages.is_empty() { println!(); - println!(" {BOLD}Alerts{RESET}"); + println!(" {}", "Alerts".bold()); for msg in &analysis.watch_messages { - println!(" {YELLOW}⚡{RESET} {msg}"); + println!(" {} {msg}", "!".yellow().bold()); } } - if let Some(highlight) = &analysis.highlight { println!(); - println!(" {GREEN}✦{RESET} {highlight}"); + println!(" {} {highlight}", "✦".green()); } - } else { - println!(" {DIM}No analysis yet — need at least one collection.{RESET}"); } - // Config summary + // Config println!(); - println!(" {BOLD}Config{RESET}"); - println!(" Window size: {}", config.window_size); - println!(" Max history: {}", config.max_history); - println!(" Cooldown: {}s", config.cooldown_seconds); + println!(" {}", "Config".bold()); + println!( + " Window: {} | Max history: {} | Cooldown: {}s", + config.window_size, config.max_history, config.cooldown_seconds + ); println!(); Ok(()) } -fn print_signal(label: &str, value: Option) { - match value { - Some(v) => println!("{label}: {v:.2}"), - None => println!("{label}: {DIM}—{RESET}"), +fn print_status_line(analysis: &Option, data_points: usize, window: usize) { + let level = if let Some(a) = analysis { + match &a.alert_level { + AlertLevel::Healthy => format!("{}", "HEALTHY".green()), + AlertLevel::Watch => format!("{}", "WATCH".yellow()), + AlertLevel::Concern => format!("{}", "CONCERN".red()), + AlertLevel::Alert => format!("{}", "ALERT".red().bold()), + } + } else { + format!("{}", "NO DATA".dimmed()) + }; + + let counts = if let Some(a) = analysis { + format!( + " | {} improving, {} stable, {} declining", + a.improving_count, a.stable_count, a.declining_count + ) + } else { + String::new() + }; + + println!(" Status: {level} {data_points} data points | window: {window}{counts}"); +} + +fn print_signal_row(name: &str, history: &[SignalVector], analysis: &Option) { + let series = stats::signal_series(history, name); + let current = series.last().copied(); + let spark = stats::sparkline(&series, SPARKLINE_WIDTH); + + // Color the value based on health thresholds + let val_str = match current { + Some(v) => { + let formatted = format!("{:.2}", v); + match signal_zone(name, v) { + Zone::Healthy => format!("{}", formatted.green()), + Zone::Watch => format!("{}", formatted.yellow()), + Zone::Concern => format!("{}", formatted.red()), + } + } + None => format!("{}", "--".dimmed()), + }; + + // Trend arrow and delta from analysis + let (arrow, delta_str) = if let Some(analysis) = analysis { + if let Some(trend) = analysis.signals.get(name) { + let arrow = match trend.trend { + Trend::Improving => format!("{}", "↑".green()), + Trend::Stable => format!("{}", "→".dimmed()), + Trend::Declining => format!("{}", "↓".red()), + }; + (arrow, format!("{:+.2}", trend.delta)) + } else { + (format!("{}", "?".dimmed()), String::new()) + } + } else { + (format!("{}", "?".dimmed()), String::new()) + }; + + // Rarity indicator + let rarity = if let Some(v) = current { + if let (Some(m), Some(sd)) = (stats::mean(&series), stats::std_dev(&series)) { + if sd > f64::EPSILON { + let z = stats::z_score(v, m, sd); + if z.abs() >= 2.0 { + format!("{}", "**".red()) + } else if z.abs() >= 1.0 { + format!("{}", "*".yellow()) + } else { + " ".to_string() + } + } else { + " ".to_string() + } + } else { + " ".to_string() + } + } else { + " ".to_string() + }; + + println!( + " {:<24} {:>6} {} {} {:>6} {}", + friendly_name(name), + val_str, + spark, + arrow, + delta_str, + rarity, + ); +} + +fn print_stats_row(name: &str, history: &[SignalVector]) { + let series = stats::signal_series(history, name); + if series.is_empty() { + return; + } + + let m = stats::mean(&series).unwrap_or(0.0); + let sd = stats::std_dev(&series).unwrap_or(0.0); + let current = series.last().copied().unwrap_or(0.0); + let pctl = stats::percentile_rank(current, &series); + let (streak_dir, streak_count) = stats::streak(&series); + + let streak_sym = match streak_dir { + 1 => "↑", + -1 => "↓", + _ => "=", + }; + + println!( + " {:<24} mean {:.2} sd {:.2} pctl {:>3.0}% streak {}{:>2}", + friendly_name(name), + m, + sd, + pctl, + streak_sym, + streak_count, + ); +} + +fn detect_anomalies(history: &[SignalVector]) -> Vec { + let mut anomalies = Vec::new(); + for &name in &SIGNAL_NAMES { + let series = stats::signal_series(history, name); + if series.len() < 5 { + continue; + } + let current = match series.last() { + Some(v) => *v, + None => continue, + }; + let m = match stats::mean(&series) { + Some(v) => v, + None => continue, + }; + let sd = match stats::std_dev(&series) { + Some(v) if v > f64::EPSILON => v, + _ => continue, + }; + let z = stats::z_score(current, m, sd); + let pctl = stats::percentile_rank(current, &series); + + if z.abs() >= 2.0 { + let direction = if z > 0.0 { "above" } else { "below" }; + anomalies.push(format!( + "{} current reading ({:.2}) is {:.1} std devs {} mean ({}th percentile)", + friendly_name(name), + current, + z.abs(), + direction, + pctl as usize, + )); + } + } + anomalies +} + +// --- JSON output --- + +fn print_json( + config: &Config, + history: &[SignalVector], + analysis: &Option, +) -> Result<(), String> { + let mut output = serde_json::Map::new(); + + // Per-signal stats + let mut signals_json = serde_json::Map::new(); + for &name in &SIGNAL_NAMES { + let series = stats::signal_series(history, name); + let mut sig = serde_json::Map::new(); + + let current = series.last().copied(); + sig.insert("current".into(), json_opt(current)); + sig.insert("mean".into(), json_opt(stats::mean(&series))); + sig.insert("std_dev".into(), json_opt(stats::std_dev(&series))); + sig.insert( + "sparkline".into(), + serde_json::Value::String(stats::sparkline(&series, SPARKLINE_WIDTH)), + ); + + if let Some(v) = current { + if let Some(n) = serde_json::Number::from_f64(stats::percentile_rank(v, &series)) { + sig.insert("percentile".into(), serde_json::Value::Number(n)); + } + if let (Some(m), Some(sd)) = (stats::mean(&series), stats::std_dev(&series)) { + if sd > f64::EPSILON { + if let Some(n) = serde_json::Number::from_f64(stats::z_score(v, m, sd)) { + sig.insert("z_score".into(), serde_json::Value::Number(n)); + } + } + } + let zone = match signal_zone(name, v) { + Zone::Healthy => "healthy", + Zone::Watch => "watch", + Zone::Concern => "concern", + }; + sig.insert("health_zone".into(), serde_json::Value::String(zone.into())); + } + + let (streak_dir, streak_count) = stats::streak(&series); + sig.insert( + "streak_direction".into(), + serde_json::Value::Number(serde_json::Number::from(streak_dir as i64)), + ); + sig.insert( + "streak_count".into(), + serde_json::Value::Number(serde_json::Number::from(streak_count as u64)), + ); + + if let Some(analysis) = analysis { + if let Some(trend) = analysis.signals.get(name) { + sig.insert( + "trend".into(), + serde_json::Value::String(format!("{:?}", trend.trend)), + ); + if let Some(n) = serde_json::Number::from_f64(trend.delta) { + sig.insert("delta".into(), serde_json::Value::Number(n)); + } + } + } + + signals_json.insert(name.into(), serde_json::Value::Object(sig)); + } + output.insert("signals".into(), serde_json::Value::Object(signals_json)); + + // Alert level + if let Some(analysis) = analysis { + output.insert( + "alert_level".into(), + serde_json::Value::String(format!("{:?}", analysis.alert_level)), + ); + output.insert( + "data_points".into(), + serde_json::Value::Number(serde_json::Number::from(analysis.data_points as u64)), + ); + output.insert( + "watch_messages".into(), + serde_json::Value::Array( + analysis + .watch_messages + .iter() + .map(|m| serde_json::Value::String(m.clone())) + .collect(), + ), + ); + } + + // Anomalies + let anomalies = detect_anomalies(history); + output.insert( + "anomalies".into(), + serde_json::Value::Array( + anomalies + .iter() + .map(|a| serde_json::Value::String(a.clone())) + .collect(), + ), + ); + + // Config + let mut cfg = serde_json::Map::new(); + cfg.insert( + "window_size".into(), + serde_json::Value::Number(serde_json::Number::from(config.window_size as u64)), + ); + cfg.insert( + "max_history".into(), + serde_json::Value::Number(serde_json::Number::from(config.max_history as u64)), + ); + output.insert("config".into(), serde_json::Value::Object(cfg)); + + let json_str = serde_json::to_string_pretty(&serde_json::Value::Object(output)) + .map_err(|e| format!("JSON serialization failed: {e}"))?; + println!("{json_str}"); + + Ok(()) +} + +fn json_opt(opt: Option) -> serde_json::Value { + opt.and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null) +} + +// --- Helpers --- + +enum Zone { + Healthy, + Watch, + Concern, +} + +fn signal_zone(name: &str, value: f64) -> Zone { + let (red_below, yellow_below) = match name { + "vocabulary_diversity" => (0.25, 0.40), + "question_generation" => (2.0, 4.0), + "thought_lifecycle" => (0.15, 0.30), + "evidence_grounding" => (0.40, 0.60), + _ => (0.25, 0.50), + }; + if value < red_below { + Zone::Concern + } else if value < yellow_below { + Zone::Watch + } else { + Zone::Healthy } }