Skip to main content

agentstatedeveloper_cli/commands/
scorecard.rs

1//! `asd scorecard` — benchmark scorecard across the five dimensions.
2//!
3//! Dimensions (each 0-100):
4//!   truth       — % symbols with verified effects + ownership ledger entries
5//!   feedback    — feedback entries recorded (50+ = 100%)
6//!   change      — % symbols with invariant or validation scenario ledger entries
7//!   uncertainty — index health proxy: symbol count + effect verification rate
8//!   workflow    — ledger entry density + CTX-tagged entries presence
9//!
10//! Per-dimension drill-down: `--drill-down <dim>`
11//! Trend vs previous run:    `--trend`
12
13use 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    /// Emit machine-readable JSON instead of the default table.
30    #[arg(long)]
31    pub json: bool,
32
33    /// Suppress the stale-index warning.
34    #[arg(long)]
35    pub quiet: bool,
36
37    /// Show per-symbol breakdown for a specific dimension.
38    /// Values: truth, feedback, change, uncertainty, workflow
39    #[arg(long)]
40    pub drill_down: Option<String>,
41
42    /// Compare current scores against the previous stored snapshot and show trends.
43    #[arg(long)]
44    pub trend: bool,
45
46    /// Override the history file path (default: <db_path>.scorecard-history.json).
47    #[arg(long)]
48    pub history_path: Option<PathBuf>,
49
50    /// Max symbols shown in --drill-down output (default: 10).
51    #[arg(long, default_value_t = 10)]
52    pub limit: usize,
53
54    /// Named scope alias from .asd/scopes.toml — evaluate only that subsystem.
55    #[arg(long)]
56    pub scope: Option<String>,
57
58    /// Comma-separated glob patterns — restrict scoring to matching file paths.
59    #[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    // Best-effort: scorecard history is observability metadata. A
91    // write failure (read-only fs, disk full) shouldn't fail the
92    // user-facing scorecard command — the score is already printed.
93    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    // All gathering and arithmetic lives in core::scorecard — this command
107    // owns presentation (the table, the trend snapshot) and nothing else.
108    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        // Terminal-flavoured advice, which is why the phrasing stays here
122        // rather than in core: over HTTP "--scope/--paths" means nothing.
123        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    // Trend computation.
155    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                // `Scores::get` keeps this list and the struct in step — a
170                // new dimension added to one shows up in the other.
171                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    // Save snapshot after computing trend (so we don't compare to ourselves).
194    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    // data_quality / details / token_economy all come from the shared
208    // envelope now — see core::scorecard::Scorecard::to_json.
209
210    if args.json {
211        // Shared envelope, then the two things only this command adds: when
212        // it ran, and how it compares to the last run.
213        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    // Human-readable table.
233    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        // `omitted` is what core actually withheld, which is not always
312        // `total - limit`: a run with fewer gaps than the limit omits none.
313        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}