Skip to main content

agentstatedeveloper_core/
scorecard.rs

1//! The five-dimension benchmark scorecard — truth, feedback, change,
2//! uncertainty, workflow — plus the data-quality and token-economy blocks
3//! that qualify them.
4//!
5//! **This is the only implementation of the arithmetic.** It previously
6//! existed three times over: `asd scorecard`, the `scorecard` MCP tool, and
7//! the `/api/v1/scorecard` handler each carried their own copy of the same
8//! loop and the same formulas. That is the shape of defect this codebase has
9//! already paid for once — see the calibration-table axis inversion in
10//! CLAUDE.md, where every unit test passed because each test encoded the same
11//! wrong assumption as the code it covered. Three copies of a formula are
12//! three places for it to drift, each with its own tests agreeing with it.
13//!
14//! What stays with the callers is *phrasing and envelope*, not computation:
15//! the CLI renders a table and keeps a trend snapshot, the HTTP handler wraps
16//! the payload in a timestamp, and each composes its own advice string for
17//! the "nothing matched" case, because "try broadening --scope/--paths" is
18//! good counsel in a terminal and meaningless over HTTP. Everything that
19//! produces a number lives here.
20
21use std::collections::{HashMap, HashSet};
22use std::path::Path;
23
24use serde::Serialize;
25use serde_json::{Value, json};
26
27use crate::candidates::{glob_match, resolve_scope};
28use crate::effects::{AsgEffectStore, EffectStore};
29use crate::engine::Engine;
30use crate::feedback::{AsgFeedbackStore, FeedbackStore};
31use crate::schema::{LedgerEntry, LedgerKind, Symbol, VerificationStatus};
32use crate::search_fts::estimate_tokens;
33
34/// Rough source-token density used for the token-economy baseline: the cost
35/// of *reading the file* a symbol lives in, against ASD's structured index
36/// entry for it. An estimate, and labelled as one wherever it surfaces.
37const TOKENS_PER_LINE: usize = 9;
38
39/// Below this many ledger entries per symbol the scores describe how little
40/// has been recorded, not how well the workflow is going — so every caller
41/// surfaces the caveat rather than the bare number.
42const SPARSE_LEDGER_DENSITY: f64 = 0.5;
43
44/// Feedback entries that read as a full-marks corpus.
45const FEEDBACK_TARGET: f64 = 50.0;
46
47/// Symbol count past which the uncertainty dimension stops rewarding volume.
48const VOLUME_TARGET: f64 = 500.0;
49
50#[derive(Debug, Clone, Default)]
51pub struct ScorecardOptions<'a> {
52    /// Named scope alias from `.asd/scopes.toml`.
53    pub scope: Option<&'a str>,
54    /// Comma-separated glob patterns restricting which files are scored.
55    pub paths: Option<&'a str>,
56    /// Per-symbol gap listing for one dimension: truth / change / workflow /
57    /// uncertainty. Anything else yields no rows.
58    pub drill_down: Option<&'a str>,
59    /// Cap on returned drill-down rows. `total_gaps` still reports the full
60    /// count, so a truncated list never reads as a complete one.
61    pub drill_limit: usize,
62}
63
64#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
65pub struct Scores {
66    pub truth: u64,
67    pub feedback: u64,
68    pub change: u64,
69    pub uncertainty: u64,
70    pub workflow: u64,
71    pub overall: u64,
72}
73
74impl Scores {
75    pub fn get(&self, dimension: &str) -> Option<u64> {
76        Some(match dimension {
77            "truth" => self.truth,
78            "feedback" => self.feedback,
79            "change" => self.change,
80            "uncertainty" => self.uncertainty,
81            "workflow" => self.workflow,
82            "overall" => self.overall,
83            _ => return None,
84        })
85    }
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct DataQuality {
90    pub ledger_density: f64,
91    pub symbols_scored: usize,
92    pub symbols_with_any_ledger: usize,
93    pub coverage_pct: f64,
94    pub sparse_db: bool,
95    pub note: String,
96    /// The glob patterns in force, or `None` when unscoped.
97    pub scope: Option<Vec<String>>,
98}
99
100#[derive(Debug, Clone, Copy, Serialize)]
101pub struct Details {
102    pub total_symbols: usize,
103    pub verified_effects: usize,
104    pub owned_symbols: usize,
105    pub invariant_symbols: usize,
106    pub validation_symbols: usize,
107    pub feedback_entries: usize,
108    pub total_ledger_entries: usize,
109    pub ctx_tagged_ledger_entries: usize,
110}
111
112#[derive(Debug, Clone, Copy, Serialize)]
113pub struct TokenEconomy {
114    pub structured_tokens: usize,
115    pub source_read_tokens_est: usize,
116    pub reduction_pct: f64,
117    pub ratio_x: f64,
118}
119
120/// The disclaimer that must travel with [`TokenEconomy`] wherever it is
121/// rendered. Kept as a constant so no caller can quietly ship the ratio
122/// without the caveat that it is an internal estimate.
123pub const TOKEN_ECONOMY_NOTE: &str = "Internal estimate — NOT a published benchmark and NOT measured per query. \
124     Compares ASD's structured per-symbol index cost (qname + signature + first doc \
125     line) against reading the source files those symbols live in (file length \
126     estimated from symbol line spans).";
127
128#[derive(Debug, Clone, Serialize)]
129pub struct GapSymbol {
130    pub qname: String,
131    pub file: String,
132    pub has_verified_effects: bool,
133    pub has_ownership: bool,
134    pub has_invariant: bool,
135    pub has_validation_scenario: bool,
136    pub ledger_entries: usize,
137    pub ctx_tagged: bool,
138}
139
140#[derive(Debug, Clone, Serialize)]
141pub struct DrillDown {
142    pub dimension: String,
143    /// Every gap found, not just the returned ones.
144    pub total_gaps: usize,
145    pub shown: usize,
146    pub omitted: usize,
147    pub gap_symbols: Vec<GapSymbol>,
148}
149
150#[derive(Debug, Clone)]
151pub struct Scorecard {
152    pub scores: Scores,
153    pub data_quality: DataQuality,
154    pub details: Details,
155    pub token_economy: TokenEconomy,
156    pub drill_down: Option<DrillDown>,
157    /// True when no symbol matched — every score is zero because there was
158    /// nothing to score, which is a different claim from "scored zero".
159    /// Callers phrase their own advice for this case.
160    pub matched_nothing: bool,
161    /// True when a scope/paths filter was applied at all. With
162    /// `matched_nothing`, distinguishes "your filter matched nothing" from
163    /// "this repo has never been indexed".
164    pub scoped: bool,
165}
166
167impl Scorecard {
168    /// The shared JSON envelope: `capability_scores` + the legacy `scores`
169    /// alias, the qualifying blocks, and the drill-down when one was asked
170    /// for.
171    ///
172    /// `scores` duplicates `capability_scores` because the CLI's trend
173    /// snapshots on disk are keyed on `scores` — dropping it would orphan
174    /// every stored history file. Callers add their own `timestamp` / `trend`
175    /// / `note` around this.
176    pub fn to_json(&self) -> Value {
177        let scores = json!(self.scores);
178        let mut out = json!({
179            "capability_scores": scores,
180            "scores": scores,
181            "data_quality": self.data_quality,
182            "details": self.details,
183            "token_economy": {
184                "note": TOKEN_ECONOMY_NOTE,
185                "structured_tokens": self.token_economy.structured_tokens,
186                "source_read_tokens_est": self.token_economy.source_read_tokens_est,
187                "reduction_pct": self.token_economy.reduction_pct,
188                "ratio_x": self.token_economy.ratio_x,
189            },
190        });
191        if let Some(drill) = &self.drill_down {
192            out.as_object_mut()
193                .expect("json! built an object")
194                .insert("drill_down".into(), json!(drill));
195        }
196        out
197    }
198}
199
200/// Score the indexed symbol set.
201///
202/// One bulk read of the by-qname index and one of the ledger tree, then a
203/// single pass — not N per-symbol git reads. `db_path` is needed only to
204/// resolve a named `scope` alias against `.asd/scopes.toml`.
205pub fn compute(engine: &Engine, db_path: &Path, opts: &ScorecardOptions<'_>) -> Scorecard {
206    let ref_name = engine.ref_name.clone();
207    let effect_store = AsgEffectStore::from_engine(engine);
208    let feedback_store = AsgFeedbackStore::from_engine(engine);
209
210    let mut paths_filter: Vec<String> = Vec::new();
211    if let Some(s) = opts.scope {
212        paths_filter.extend(resolve_scope(s, db_path));
213    }
214    if let Some(p) = opts.paths {
215        paths_filter.extend(
216            p.split(',')
217                .map(|x| x.trim().to_string())
218                .filter(|x| !x.is_empty()),
219        );
220    }
221    let scoped = !paths_filter.is_empty();
222
223    let all_syms: Vec<Symbol> = {
224        let tree = engine
225            .repo
226            .get_tree(&ref_name, "/asd/v1/index/by-qname")
227            .unwrap_or(Value::Object(Default::default()));
228        tree.as_object()
229            .map(|m| {
230                m.values()
231                    .filter_map(|v| serde_json::from_value::<Symbol>(v.clone()).ok())
232                    .collect()
233            })
234            .unwrap_or_default()
235    };
236
237    let scored_syms: Vec<&Symbol> = if scoped {
238        all_syms
239            .iter()
240            .filter(|s| paths_filter.iter().any(|p| glob_match(p, &s.file)))
241            .collect()
242    } else {
243        all_syms.iter().collect()
244    };
245    let total_symbols = scored_syms.len();
246
247    if total_symbols == 0 {
248        return empty(scoped, paths_filter);
249    }
250
251    let ledger_by_sym = load_ledger(engine, &ref_name);
252
253    let drill = opts.drill_down.unwrap_or("").to_lowercase();
254    let need_drill = !drill.is_empty();
255    let mut drill_rows: Vec<GapSymbol> = Vec::new();
256
257    let mut verified_count = 0usize;
258    let mut owned_count = 0usize;
259    let mut has_invariant = 0usize;
260    let mut has_validation = 0usize;
261    let mut total_ledger_entries = 0usize;
262    let mut ctx_tagged_entries = 0usize;
263
264    let mut structured_tokens = 0usize;
265    // Longest symbol span per file stands in for the file's length — the
266    // index knows line spans without reading anything off disk.
267    let mut file_max_line: HashMap<&str, u32> = HashMap::new();
268
269    for sym in &scored_syms {
270        let has_verified =
271            if let Ok(Some(decl)) = effect_store.get_effects(&ref_name, &sym.symbol_id) {
272                decl.verification
273                    .as_ref()
274                    .map(|v| matches!(v.status, VerificationStatus::Ok))
275                    .unwrap_or(false)
276            } else {
277                false
278            };
279        if has_verified {
280            verified_count += 1;
281        }
282
283        let record = format!(
284            "{} {} {}",
285            sym.qname,
286            sym.signature.as_deref().unwrap_or(""),
287            sym.doc
288                .as_deref()
289                .unwrap_or("")
290                .lines()
291                .next()
292                .unwrap_or("")
293        );
294        structured_tokens += estimate_tokens(&record);
295        let f = file_max_line.entry(sym.file.as_str()).or_insert(0);
296        *f = (*f).max(sym.end.line);
297
298        let entries = ledger_by_sym
299            .get(&sym.symbol_id)
300            .map(Vec::as_slice)
301            .unwrap_or(&[]);
302        total_ledger_entries += entries.len();
303
304        let mut sym_owned = false;
305        let mut sym_inv = false;
306        let mut sym_vs = false;
307        let mut sym_ctx = false;
308        for entry in entries {
309            match entry.kind {
310                LedgerKind::Invariant => sym_inv = true,
311                LedgerKind::ValidationScenario => sym_vs = true,
312                LedgerKind::Ownership => sym_owned = true,
313                _ => {}
314            }
315            if entry.tags.iter().any(|t| t.starts_with("ctx:")) {
316                sym_ctx = true;
317                ctx_tagged_entries += 1;
318            }
319        }
320        if sym_owned {
321            owned_count += 1;
322        }
323        if sym_inv {
324            has_invariant += 1;
325        }
326        if sym_vs {
327            has_validation += 1;
328        }
329
330        if need_drill {
331            let include = match drill.as_str() {
332                "truth" => !has_verified || !sym_owned,
333                "change" => !sym_inv || !sym_vs,
334                "workflow" => entries.is_empty() || !sym_ctx,
335                "uncertainty" => !has_verified,
336                _ => false,
337            };
338            if include {
339                drill_rows.push(GapSymbol {
340                    qname: sym.qname.clone(),
341                    file: sym.file.clone(),
342                    has_verified_effects: has_verified,
343                    has_ownership: sym_owned,
344                    has_invariant: sym_inv,
345                    has_validation_scenario: sym_vs,
346                    ledger_entries: entries.len(),
347                    ctx_tagged: sym_ctx,
348                });
349            }
350        }
351    }
352
353    let feedback_count = feedback_store
354        .list_all(&ref_name)
355        .map(|v| v.len())
356        .unwrap_or(0);
357
358    let details = Details {
359        total_symbols,
360        verified_effects: verified_count,
361        owned_symbols: owned_count,
362        invariant_symbols: has_invariant,
363        validation_symbols: has_validation,
364        feedback_entries: feedback_count,
365        total_ledger_entries,
366        ctx_tagged_ledger_entries: ctx_tagged_entries,
367    };
368    let scores = score(&details);
369
370    let total = total_symbols as f64;
371    let ledger_density = total_ledger_entries as f64 / total;
372    let sparse_db = ledger_density < SPARSE_LEDGER_DENSITY;
373    let with_ledger = scored_syms
374        .iter()
375        .filter(|s| ledger_by_sym.contains_key(&s.symbol_id))
376        .count();
377
378    let source_read_tokens: usize = file_max_line
379        .values()
380        .map(|&l| l as usize * TOKENS_PER_LINE)
381        .sum();
382    let reduction_pct = if source_read_tokens > 0 {
383        (1.0 - structured_tokens as f64 / source_read_tokens as f64) * 100.0
384    } else {
385        0.0
386    };
387    let ratio_x = if structured_tokens > 0 {
388        source_read_tokens as f64 / structured_tokens as f64
389    } else {
390        0.0
391    };
392
393    Scorecard {
394        scores,
395        data_quality: DataQuality {
396            ledger_density,
397            symbols_scored: total_symbols,
398            symbols_with_any_ledger: with_ledger,
399            coverage_pct: (with_ledger as f64 / total * 100.0).round(),
400            sparse_db,
401            note: if sparse_db {
402                sparse_note(total_ledger_entries, total_symbols, ledger_density)
403            } else {
404                "ledger density is adequate".to_string()
405            },
406            scope: scoped.then_some(paths_filter),
407        },
408        details,
409        token_economy: TokenEconomy {
410            structured_tokens,
411            source_read_tokens_est: source_read_tokens,
412            reduction_pct: (reduction_pct * 10.0).round() / 10.0,
413            ratio_x: (ratio_x * 10.0).round() / 10.0,
414        },
415        drill_down: need_drill.then(|| {
416            let total_gaps = drill_rows.len();
417            let shown: Vec<GapSymbol> = drill_rows.into_iter().take(opts.drill_limit).collect();
418            DrillDown {
419                dimension: drill,
420                total_gaps,
421                shown: shown.len(),
422                omitted: total_gaps.saturating_sub(shown.len()),
423                gap_symbols: shown,
424            }
425        }),
426        matched_nothing: false,
427        scoped,
428    }
429}
430
431/// The sparse-ledger caveat. Identical wording across every surface, so it
432/// lives with the threshold that triggers it rather than being retyped
433/// alongside each renderer.
434pub fn sparse_note(entries: usize, symbols: usize, density: f64) -> String {
435    format!(
436        "sparse ledger ({entries} entries across {symbols} symbols, \
437         {density:.2} avg) — run 'asd sync' + 'asd hydrate' to populate; \
438         scores reflect data density, not workflow quality"
439    )
440}
441
442/// The formulas, isolated from the gathering so they can be tested against
443/// hand-built metrics without an engine.
444///
445/// Directionality, since it is the thing most easily got backwards: every
446/// dimension here runs *good-is-high*. That is the opposite of
447/// `uncertainty.level` in `candidates`, where `low` means low uncertainty and
448/// therefore high confidence. The `uncertainty` score below is an
449/// index-health proxy — more verified effects and more symbols score higher —
450/// not a measure of how uncertain anything is.
451pub fn score(m: &Details) -> Scores {
452    let total = m.total_symbols as f64;
453    if m.total_symbols == 0 {
454        return Scores {
455            truth: 0,
456            feedback: 0,
457            change: 0,
458            uncertainty: 0,
459            workflow: 0,
460            overall: 0,
461        };
462    }
463
464    let truth = ((m.verified_effects as f64 / total + m.owned_symbols as f64 / total) / 2.0
465        * 100.0)
466        .min(100.0);
467    let feedback = (m.feedback_entries as f64 / FEEDBACK_TARGET * 100.0).min(100.0);
468    let change = ((m.invariant_symbols as f64 / total + m.validation_symbols as f64 / total) / 2.0
469        * 100.0)
470        .min(100.0);
471    let uncertainty = {
472        let effect_rate = m.verified_effects as f64 / total;
473        let volume_score = (total / VOLUME_TARGET).min(1.0);
474        ((effect_rate + volume_score) / 2.0 * 100.0).min(100.0)
475    };
476    let workflow = {
477        let density = (m.total_ledger_entries as f64 / total / 2.0).min(1.0);
478        let ctx_adoption = if m.total_ledger_entries == 0 {
479            0.0
480        } else {
481            (m.ctx_tagged_ledger_entries as f64 / m.total_ledger_entries as f64).min(1.0)
482        };
483        ((density * 0.6 + ctx_adoption * 0.4) * 100.0).min(100.0)
484    };
485    let overall = (truth + feedback + change + uncertainty + workflow) / 5.0;
486
487    Scores {
488        truth: truth.round() as u64,
489        feedback: feedback.round() as u64,
490        change: change.round() as u64,
491        uncertainty: uncertainty.round() as u64,
492        workflow: workflow.round() as u64,
493        overall: overall.round() as u64,
494    }
495}
496
497/// One tree read for the whole ledger, superseded entries dropped, keyed by
498/// symbol id.
499fn load_ledger(engine: &Engine, ref_name: &str) -> HashMap<String, Vec<LedgerEntry>> {
500    let tree = engine
501        .repo
502        .get_tree(ref_name, "/asd/v1/ledger")
503        .unwrap_or(Value::Object(Default::default()));
504    let mut map: HashMap<String, Vec<LedgerEntry>> = HashMap::new();
505    if let Value::Object(by_symbol) = tree {
506        for (sym_id, per_symbol) in by_symbol {
507            if let Value::Object(entries_map) = per_symbol {
508                let mut entries: Vec<LedgerEntry> = entries_map
509                    .values()
510                    .filter_map(|v| serde_json::from_value::<LedgerEntry>(v.clone()).ok())
511                    .collect();
512                entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
513                let superseded: HashSet<String> = entries
514                    .iter()
515                    .flat_map(|e| e.supersedes.iter().cloned())
516                    .collect();
517                entries.retain(|e| !superseded.contains(&e.entry_id));
518                map.insert(sym_id, entries);
519            }
520        }
521    }
522    map
523}
524
525fn empty(scoped: bool, paths_filter: Vec<String>) -> Scorecard {
526    let details = Details {
527        total_symbols: 0,
528        verified_effects: 0,
529        owned_symbols: 0,
530        invariant_symbols: 0,
531        validation_symbols: 0,
532        feedback_entries: 0,
533        total_ledger_entries: 0,
534        ctx_tagged_ledger_entries: 0,
535    };
536    Scorecard {
537        scores: score(&details),
538        data_quality: DataQuality {
539            ledger_density: 0.0,
540            symbols_scored: 0,
541            symbols_with_any_ledger: 0,
542            coverage_pct: 0.0,
543            sparse_db: false,
544            note: "no symbols to score".to_string(),
545            scope: scoped.then_some(paths_filter),
546        },
547        details,
548        token_economy: TokenEconomy {
549            structured_tokens: 0,
550            source_read_tokens_est: 0,
551            reduction_pct: 0.0,
552            ratio_x: 0.0,
553        },
554        drill_down: None,
555        matched_nothing: true,
556        scoped,
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    fn metrics(total: usize) -> Details {
565        Details {
566            total_symbols: total,
567            verified_effects: 0,
568            owned_symbols: 0,
569            invariant_symbols: 0,
570            validation_symbols: 0,
571            feedback_entries: 0,
572            total_ledger_entries: 0,
573            ctx_tagged_ledger_entries: 0,
574        }
575    }
576
577    #[test]
578    fn an_empty_index_scores_zero_rather_than_dividing_by_it() {
579        let s = score(&metrics(0));
580        assert_eq!(s.overall, 0);
581        assert_eq!(s.uncertainty, 0);
582    }
583
584    #[test]
585    fn every_dimension_is_good_is_high() {
586        // Guards the axis. `uncertainty` here is an index-health proxy, not a
587        // measure of doubt: a fully verified, high-volume index must score
588        // HIGH, the opposite direction from `uncertainty.level` elsewhere in
589        // the codebase. See the note on `score`.
590        let mut m = metrics(1000);
591        m.verified_effects = 1000;
592        m.owned_symbols = 1000;
593        m.invariant_symbols = 1000;
594        m.validation_symbols = 1000;
595        m.feedback_entries = 50;
596        m.total_ledger_entries = 4000;
597        m.ctx_tagged_ledger_entries = 4000;
598
599        let s = score(&m);
600        assert_eq!(s.truth, 100);
601        assert_eq!(s.change, 100);
602        assert_eq!(s.feedback, 100);
603        assert_eq!(s.uncertainty, 100, "a fully verified index is the GOOD end");
604        assert_eq!(s.workflow, 100);
605        assert_eq!(s.overall, 100);
606
607        let bare = score(&metrics(1000));
608        assert_eq!(bare.truth, 0);
609        assert_eq!(bare.change, 0);
610        assert_eq!(bare.workflow, 0);
611        assert!(
612            bare.uncertainty < s.uncertainty,
613            "an unverified index must score LOWER, not higher"
614        );
615    }
616
617    #[test]
618    fn no_dimension_exceeds_one_hundred() {
619        // Every formula is capped; a store with far more feedback or ledger
620        // entries than the targets must not overflow the scale.
621        let mut m = metrics(100_000);
622        m.verified_effects = 100_000;
623        m.owned_symbols = 100_000;
624        m.feedback_entries = 100_000;
625        m.total_ledger_entries = 1_000_000;
626        m.ctx_tagged_ledger_entries = 1_000_000;
627        m.invariant_symbols = 100_000;
628        m.validation_symbols = 100_000;
629
630        let s = score(&m);
631        for (name, v) in [
632            ("truth", s.truth),
633            ("feedback", s.feedback),
634            ("change", s.change),
635            ("uncertainty", s.uncertainty),
636            ("workflow", s.workflow),
637            ("overall", s.overall),
638        ] {
639            assert!(v <= 100, "{name} = {v}");
640        }
641    }
642
643    #[test]
644    fn workflow_weights_density_above_ctx_adoption() {
645        // 60/40 split: full density with no CTX tagging must beat full CTX
646        // tagging on a thin ledger.
647        let mut dense = metrics(100);
648        dense.total_ledger_entries = 200;
649        let mut tagged = metrics(100);
650        tagged.total_ledger_entries = 1;
651        tagged.ctx_tagged_ledger_entries = 1;
652
653        assert!(score(&dense).workflow > score(&tagged).workflow);
654    }
655
656    #[test]
657    fn scores_get_reaches_every_dimension() {
658        let s = score(&metrics(0));
659        for dim in [
660            "truth",
661            "feedback",
662            "change",
663            "uncertainty",
664            "workflow",
665            "overall",
666        ] {
667            assert!(s.get(dim).is_some(), "{dim} unreachable");
668        }
669        assert!(s.get("nonsense").is_none());
670    }
671
672    #[test]
673    fn json_keeps_the_legacy_scores_alias() {
674        // CLI trend snapshots on disk are keyed on `scores`; dropping the
675        // alias would orphan every stored history file.
676        let card = empty(false, vec![]);
677        let v = card.to_json();
678        assert_eq!(v["scores"], v["capability_scores"]);
679        assert!(
680            v["token_economy"]["note"]
681                .as_str()
682                .unwrap()
683                .contains("estimate")
684        );
685    }
686}