agentstatedeveloper_cli/commands/
scorecard.rs1use std::path::PathBuf;
14
15use anyhow::Result;
16use chrono::Utc;
17use clap::Args;
18use serde_json::{Value, json};
19
20use agentstatedeveloper_core::{
21 Engine,
22 scorecard::{ScorecardOptions, compute},
23};
24
25use crate::config::Config;
26
27#[derive(Debug, Args)]
28pub struct ScorecardArgs {
29 #[arg(long)]
31 pub json: bool,
32
33 #[arg(long)]
35 pub quiet: bool,
36
37 #[arg(long)]
40 pub drill_down: Option<String>,
41
42 #[arg(long)]
44 pub trend: bool,
45
46 #[arg(long)]
48 pub history_path: Option<PathBuf>,
49
50 #[arg(long, default_value_t = 10)]
52 pub limit: usize,
53
54 #[arg(long)]
56 pub scope: Option<String>,
57
58 #[arg(long)]
60 pub paths: Option<String>,
61}
62
63fn history_path(cfg: &Config, override_path: Option<&PathBuf>) -> PathBuf {
64 if let Some(p) = override_path {
65 return p.clone();
66 }
67 let mut p = cfg.db_path.clone();
68 let stem = p
69 .file_stem()
70 .map(|s| format!("{}.scorecard-history.json", s.to_string_lossy()))
71 .unwrap_or_else(|| "asd-scorecard-history.json".to_string());
72 p.set_file_name(stem);
73 p
74}
75
76fn load_history(path: &PathBuf) -> Vec<Value> {
77 if !path.exists() {
78 return vec![];
79 }
80 let s = std::fs::read_to_string(path).unwrap_or_default();
81 serde_json::from_str::<Vec<Value>>(&s).unwrap_or_default()
82}
83
84fn save_snapshot(path: &PathBuf, snapshot: &Value) {
85 let mut history = load_history(path);
86 history.push(snapshot.clone());
87 if history.len() > 100 {
88 history.drain(..history.len() - 100);
89 }
90 let _ = std::fs::write(
94 path,
95 serde_json::to_string_pretty(&history).unwrap_or_default(),
96 );
97}
98
99pub fn run(cfg: &Config, args: ScorecardArgs) -> Result<()> {
100 if !args.quiet {
101 if let Some(warn) = agentstatedeveloper_core::stale_warning(&cfg.db_path, 3600) {
102 eprintln!("{warn}");
103 }
104 }
105
106 let engine = Engine::open_sqlite(&cfg.db_path)?;
109 let card = compute(
110 &engine,
111 &cfg.db_path,
112 &ScorecardOptions {
113 scope: args.scope.as_deref(),
114 paths: args.paths.as_deref(),
115 drill_down: args.drill_down.as_deref(),
116 drill_limit: args.limit,
117 },
118 );
119
120 if card.matched_nothing {
121 let note = if card.scoped {
124 "no symbols matched the path filter — try broadening --scope/--paths"
125 } else {
126 "no symbols indexed — run `asd index` first"
127 };
128 let zero = json!({
129 "note": note,
130 "scores": { "truth": 0, "feedback": 0, "change": 0, "uncertainty": 0, "workflow": 0, "overall": 0 }
131 });
132 println!("{}", serde_json::to_string_pretty(&zero)?);
133 return Ok(());
134 }
135
136 let scores = card.scores;
137 let scoped = card.scoped;
138 let paths_filter = card.data_quality.scope.clone().unwrap_or_default();
139 let total_symbols = card.details.total_symbols;
140 let feedback_count = card.details.feedback_entries;
141 let total_ledger_entries = card.details.total_ledger_entries;
142 let ctx_tagged_entries = card.details.ctx_tagged_ledger_entries;
143 let structured_tokens = card.token_economy.structured_tokens;
144 let source_read_tokens = card.token_economy.source_read_tokens_est;
145 let ratio_x = card.token_economy.ratio_x;
146 let sparse_note: Option<&str> = card
147 .data_quality
148 .sparse_db
149 .then_some(card.data_quality.note.as_str());
150
151 let now = Utc::now().to_rfc3339();
152 let hist_path = history_path(cfg, args.history_path.as_ref());
153
154 let trend_obj: Option<Value> = if args.trend {
156 let history = load_history(&hist_path);
157 history.last().map(|prev| {
158 let prev_scores = prev.get("scores").cloned().unwrap_or(json!({}));
159 let dims = [
160 "truth",
161 "feedback",
162 "change",
163 "uncertainty",
164 "workflow",
165 "overall",
166 ];
167 let mut deltas = serde_json::Map::new();
168 for dim in dims {
169 let current = scores.get(dim).unwrap_or(scores.overall) as i64;
172 let prev_val = prev_scores.get(dim).and_then(Value::as_i64).unwrap_or(0);
173 let delta = current - prev_val;
174 deltas.insert(
175 dim.to_string(),
176 json!({
177 "current": current,
178 "previous": prev_val,
179 "delta": delta,
180 "trend": if delta > 0 { "▲" } else if delta < 0 { "▼" } else { "─" },
181 }),
182 );
183 }
184 json!({
185 "compared_to": prev.get("timestamp").cloned().unwrap_or(json!("unknown")),
186 "dimensions": deltas,
187 })
188 })
189 } else {
190 None
191 };
192
193 let snapshot = json!({
195 "timestamp": now,
196 "scores": {
197 "truth": scores.truth,
198 "feedback": scores.feedback,
199 "change": scores.change,
200 "uncertainty": scores.uncertainty,
201 "workflow": scores.workflow,
202 "overall": scores.overall,
203 },
204 });
205 save_snapshot(&hist_path, &snapshot);
206
207 if args.json {
211 let mut out = json!({ "timestamp": now });
214 let obj = out.as_object_mut().expect("json! built an object");
215 for (k, v) in card
216 .to_json()
217 .as_object()
218 .expect("to_json built an object")
219 .clone()
220 {
221 obj.insert(k, v);
222 }
223 if let Some(ref t) = trend_obj {
224 out.as_object_mut()
225 .unwrap()
226 .insert("trend".into(), t.clone());
227 }
228 println!("{}", serde_json::to_string_pretty(&out)?);
229 return Ok(());
230 }
231
232 println!("ASD Benchmark Scorecard");
234 if scoped {
235 println!(" scope: {}", paths_filter.join(", "));
236 }
237 println!("{:-<40}", "");
238 let dim_names = [
239 ("Truth Model ", "truth", scores.truth),
240 ("Feedback Loop ", "feedback", scores.feedback),
241 ("Change Model ", "change", scores.change),
242 ("Uncertainty Model", "uncertainty", scores.uncertainty),
243 ("Workflow ", "workflow", scores.workflow),
244 ];
245 for (label, key, val) in &dim_names {
246 let bar: String = (0..val / 5)
247 .map(|_| '█')
248 .chain((val / 5..20).map(|_| '░'))
249 .collect();
250 let trend_suffix = if let Some(ref t) = trend_obj {
251 let sym = t
252 .get("dimensions")
253 .and_then(|d| d.get(*key))
254 .and_then(|d| d.get("trend"))
255 .and_then(Value::as_str)
256 .unwrap_or("─");
257 let delta = t
258 .get("dimensions")
259 .and_then(|d| d.get(*key))
260 .and_then(|d| d.get("delta"))
261 .and_then(Value::as_i64)
262 .unwrap_or(0);
263 format!(" {sym}{:+}", delta)
264 } else {
265 String::new()
266 };
267 println!("{label} {bar} {:3}/100{trend_suffix}", val);
268 }
269 println!("{:-<40}", "");
270 let trend_overall = if let Some(ref t) = trend_obj {
271 let sym = t
272 .get("dimensions")
273 .and_then(|d| d.get("overall"))
274 .and_then(|d| d.get("trend"))
275 .and_then(Value::as_str)
276 .unwrap_or("─");
277 let delta = t
278 .get("dimensions")
279 .and_then(|d| d.get("overall"))
280 .and_then(|d| d.get("delta"))
281 .and_then(Value::as_i64)
282 .unwrap_or(0);
283 format!(" {sym}{:+}", delta)
284 } else {
285 String::new()
286 };
287 println!(
288 "Overall {:3}/100{trend_overall}",
289 scores.overall
290 );
291 println!();
292 println!("Symbols indexed: {}", total_symbols);
293 println!("Feedback entries: {}", feedback_count);
294 println!("Ledger entries: {}", total_ledger_entries);
295 println!("CTX-tagged: {}", ctx_tagged_entries);
296 println!(
297 "Token economy: ~{:.1}x vs source ({} index tok vs ~{} source tok, est.)",
298 ratio_x, structured_tokens, source_read_tokens
299 );
300
301 if let Some(ref note) = sparse_note {
302 println!("\nNote: {note}");
303 }
304
305 if let Some(drill) = card.drill_down.as_ref().filter(|d| d.total_gaps > 0) {
306 let (dimension, total_gaps) = (&drill.dimension, drill.total_gaps);
307 println!("\n## Drill-down: {dimension} gaps ({total_gaps} symbols)");
308 for row in &drill.gap_symbols {
309 println!(" {} ({})", row.qname, row.file);
310 }
311 if drill.omitted > 0 {
314 println!(" … and {} more (use --limit to show more)", drill.omitted);
315 }
316 }
317
318 if args.trend {
319 if let Some(ref t) = trend_obj {
320 let compared = t
321 .get("compared_to")
322 .and_then(Value::as_str)
323 .unwrap_or("unknown");
324 println!("\nTrend vs: {compared}");
325 } else {
326 println!("\nNo previous snapshot found — this run has been saved as the baseline.");
327 }
328 }
329
330 Ok(())
331}