1use 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
34const TOKENS_PER_LINE: usize = 9;
38
39const SPARSE_LEDGER_DENSITY: f64 = 0.5;
43
44const FEEDBACK_TARGET: f64 = 50.0;
46
47const VOLUME_TARGET: f64 = 500.0;
49
50#[derive(Debug, Clone, Default)]
51pub struct ScorecardOptions<'a> {
52 pub scope: Option<&'a str>,
54 pub paths: Option<&'a str>,
56 pub drill_down: Option<&'a str>,
59 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 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
120pub 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 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 pub matched_nothing: bool,
161 pub scoped: bool,
165}
166
167impl Scorecard {
168 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
200pub 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 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
431pub 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
442pub 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
497fn 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 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 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 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 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}