agentstatedeveloper_core/trust.rs
1//! State Trust Score — machine-readable rollup of ASD semantic state quality.
2//!
3//! Answers: "Can the agent rely on ASD's output for the current task?"
4//!
5//! # Signals
6//!
7//! | Signal | Source | Max deduction |
8//! |-------------------|-------------------------------|---------------|
9//! | Index freshness | FTS `last_indexed_at` | −0.30 |
10//! | Symbol count | FTS `symbol_count` | blocking |
11//! | Sidecar state | `sidecar_lifecycle_state` | −0.10 |
12//! | Dirty files | `git status --short` | −0.05 |
13//! | Ledger density | Engine ledger tree | −0.15 |
14//! | Concept gaps | Ownership without Concept | −0.05 |
15//!
16//! # Levels
17//!
18//! - `high` — score ≥ 0.85
19//! - `medium` — score ≥ 0.65
20//! - `low` — score ≥ 0.40
21//! - `blocked` — score < 0.40 or any blocking signal fires
22
23use std::path::Path;
24
25use serde::{Deserialize, Serialize};
26use serde_json::Value;
27
28use crate::{
29 ASD_SCHEMA_VERSION, AsgIndexStore, AsgLedgerStore, Engine, LedgerStore, SearchFtsDb,
30 SidecarState,
31 schema::{LedgerKind, Symbol},
32 sidecar_lifecycle_state,
33};
34
35// ---------------------------------------------------------------------------
36// Public types
37// ---------------------------------------------------------------------------
38
39/// Raw signal values gathered before scoring. Exposed for debugging and for
40/// truth-drift history snapshots.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TrustSignals {
43 /// Index age in fractional hours (−1.0 if unknown).
44 pub age_hours: f64,
45 /// Total indexed symbols.
46 pub symbol_count: u64,
47 /// Sidecar lifecycle state key: missing / present / hydrated / fresh-reset.
48 pub sidecar_state: String,
49 /// Number of source files with uncommitted changes.
50 pub dirty_file_count: usize,
51 /// Symbols that have an Ownership entry but no Concept entry.
52 pub concept_gap_count: usize,
53 /// Ledger entries per symbol (raw float).
54 pub ledger_density: f64,
55 /// Field-test refinement (AcmeFlow, 2026-06-04): raw count of
56 /// distinct annotated symbols, sourced from live `asd_ledger_cache`.
57 /// Drives the classify_data_quality `unannotated` → `sparse_but_active`
58 /// rename when any annotations exist, even if density is tiny on a
59 /// large repo (5 entries on 9000 symbols would round to 0.0006 →
60 /// classified as `unannotated` under the old density-only gate).
61 /// `None` when FTS is unavailable.
62 pub annotated_symbol_count: Option<usize>,
63 /// ASD schema version string embedded in the binary.
64 pub schema_version: String,
65}
66
67/// Distinguishes intentional clean-room state from unexpected data loss.
68///
69/// | `state` | density | prior activity? | meaning |
70/// |--------------------|---------|-----------------|--------------------------------------|
71/// | `clean_room` | 0.0 | no | fresh init/clone — expected |
72/// | `unannotated` | 0.0 | yes | indexed but no annotations yet |
73/// | `degraded` | >0 <5% | yes | regression — possible state loss |
74/// | `sparse_but_active`| 5–50% | yes | annotation in progress |
75/// | `populated` | ≥50% | — | healthy, well-annotated |
76/// | `empty` | — | — | no symbols — run `asd index` |
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct DataQuality {
79 /// One of: "clean_room" | "sparse_but_active" | "populated" | "degraded" | "empty".
80 pub state: String,
81 /// Human-readable explanation of why this state was inferred.
82 pub reason: String,
83 /// True when sparse/low state is expected (e.g. after `asd init` or `git clone`).
84 /// False when sparse state is anomalous given prior activity.
85 pub expected_after_reset: bool,
86}
87
88/// Rolled-up state trust score for the current ASD workspace.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct TrustScore {
91 /// Normalised trust value in [0.0, 1.0], rounded to 2 d.p.
92 pub score: f64,
93 /// Human label: "high" | "medium" | "low" | "blocked".
94 pub level: String,
95 /// Sorted list of short reason tokens (additive, both positive & negative).
96 pub reasons: Vec<String>,
97 /// True when one or more blocking conditions prevent reliable use.
98 pub blocking: bool,
99 /// Raw signal values (useful for history snapshots and debugging).
100 pub signals: TrustSignals,
101 /// Distinguishes intentional clean-room DB from unexpected state degradation.
102 pub data_quality: DataQuality,
103 /// Task categories this DB state is reliable enough to support.
104 ///
105 /// Values: `"search"` | `"impact"` | `"change-planning"` | `"audit"` | `"compliance"`
106 pub safe_to_use_for: Vec<String>,
107 /// Task categories where this DB state should NOT be relied upon.
108 ///
109 /// Values: same set as `safe_to_use_for`.
110 pub avoid_for: Vec<String>,
111}
112
113impl TrustScore {
114 /// Serialize to a compact `serde_json::Value` for embedding in CLI output.
115 pub fn to_json(&self) -> Value {
116 serde_json::to_value(self).unwrap_or(Value::Null)
117 }
118}
119
120// ---------------------------------------------------------------------------
121// Core computation
122// ---------------------------------------------------------------------------
123
124/// Compute a [`TrustScore`] for the ASD workspace at `db_path`.
125///
126/// Opens the FTS database and (if available) the Engine/ASG. All errors are
127/// treated as degraded signals — this function never fails.
128pub fn compute_trust_score(db_path: &Path) -> TrustScore {
129 let project_root = db_path
130 .parent()
131 .filter(|p| !p.as_os_str().is_empty())
132 .map(|p| p.to_path_buf())
133 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
134
135 // -----------------------------------------------------------------------
136 // 1. FTS: age + symbol count
137 // -----------------------------------------------------------------------
138 let (age_hours, symbol_count) = match SearchFtsDb::open(db_path) {
139 Ok(fts) => {
140 let sym = fts.symbol_count() as u64;
141 let age = match fts.last_indexed_at() {
142 Some(ts) => {
143 let now = std::time::SystemTime::now()
144 .duration_since(std::time::UNIX_EPOCH)
145 .map(|d| d.as_secs() as i64)
146 .unwrap_or(0);
147 let secs = (now - ts).max(0) as f64;
148 secs / 3600.0
149 }
150 None => -1.0,
151 };
152 (age, sym)
153 }
154 Err(_) => (-1.0, 0),
155 };
156
157 // -----------------------------------------------------------------------
158 // 2. Sidecar lifecycle
159 // -----------------------------------------------------------------------
160 let sidecar_state = sidecar_lifecycle_state(&project_root);
161 let sidecar_key = match &sidecar_state {
162 SidecarState::Missing => "missing",
163 SidecarState::Present => "present",
164 SidecarState::Hydrated => "hydrated",
165 SidecarState::FreshReset => "fresh-reset",
166 };
167
168 // -----------------------------------------------------------------------
169 // 3. Dirty files — run from project root to match status.rs behaviour
170 // -----------------------------------------------------------------------
171 let dirty_file_count = count_dirty_source_files(&project_root);
172
173 // -----------------------------------------------------------------------
174 // 4. Ledger density + concept gaps (via Engine/ASG)
175 // -----------------------------------------------------------------------
176 let (ledger_density, concept_gap_count) = ledger_signals(db_path, symbol_count);
177
178 // 4b. Live-cache annotated symbol count — drives the
179 // `unannotated` → `sparse_but_active` reclassification when the
180 // density is tiny but real entries exist (5 entries on 9000 symbols
181 // rounds to 0.0006 density; old code mislabeled this as
182 // "no annotations yet"). See AcmeFlow field-test feedback,
183 // 2026-06-04.
184 let annotated_symbol_count = SearchFtsDb::open(db_path).ok().map(|fts| {
185 // ref_name lives on Engine; reopen here to avoid threading it
186 // through ledger_signals. compute_trust_score is called once
187 // per status/probe/etc. invocation, so the extra open is
188 // negligible.
189 let ref_name = Engine::open_sqlite(db_path)
190 .map(|e| e.ref_name)
191 .unwrap_or_else(|_| "refs/heads/main".to_string());
192 fts.annotated_symbol_count(&ref_name)
193 });
194
195 // -----------------------------------------------------------------------
196 // 5. Schema version
197 // -----------------------------------------------------------------------
198 let schema_version = ASD_SCHEMA_VERSION.to_string();
199
200 let signals = TrustSignals {
201 age_hours: (age_hours * 100.0).round() / 100.0,
202 symbol_count,
203 sidecar_state: sidecar_key.to_string(),
204 dirty_file_count,
205 concept_gap_count,
206 ledger_density: (ledger_density * 1000.0).round() / 1000.0,
207 annotated_symbol_count,
208 schema_version,
209 };
210
211 // -----------------------------------------------------------------------
212 // 6. Data quality: clean-room vs degraded vs healthy
213 // -----------------------------------------------------------------------
214 let data_quality = classify_data_quality(&signals, &project_root);
215
216 score_signals(&signals, sidecar_key, &data_quality)
217}
218
219// ---------------------------------------------------------------------------
220// Scoring algorithm
221// ---------------------------------------------------------------------------
222
223fn score_signals(sig: &TrustSignals, sidecar_key: &str, dq: &DataQuality) -> TrustScore {
224 let mut score: f64 = 1.0;
225 let mut reasons: Vec<String> = Vec::new();
226 let mut blocking = false;
227
228 // --- Blocking: empty index --------------------------------------------
229 if sig.symbol_count == 0 {
230 score = 0.0;
231 blocking = true;
232 reasons.push("empty_index".to_string());
233 return finish(score, reasons, blocking, sig, dq);
234 }
235
236 // --- Index freshness --------------------------------------------------
237 if sig.age_hours < 0.0 {
238 // Unknown — treat as mildly stale (no timestamp in DB)
239 score -= 0.10;
240 reasons.push("index_age_unknown".to_string());
241 } else if sig.age_hours < 1.0 {
242 reasons.push("fresh_index".to_string());
243 } else if sig.age_hours < 24.0 {
244 score -= 0.15;
245 reasons.push("stale_index".to_string());
246 } else {
247 score -= 0.30;
248 reasons.push("very_stale_index".to_string());
249 }
250
251 // --- Sidecar ----------------------------------------------------------
252 match sidecar_key {
253 "hydrated" => {
254 reasons.push("sidecar_hydrated".to_string());
255 }
256 "present" => {
257 // Sidecar exists but not loaded into ASG — minor deduction.
258 score -= 0.05;
259 reasons.push("sidecar_present".to_string());
260 }
261 "fresh-reset" => {
262 score -= 0.10;
263 reasons.push("sidecar_fresh_reset".to_string());
264 }
265 _ => {
266 // Missing
267 score -= 0.10;
268 reasons.push("sidecar_missing".to_string());
269 }
270 }
271
272 // --- Dirty files (informational) --------------------------------------
273 if sig.dirty_file_count > 0 {
274 score -= 0.05;
275 reasons.push("dirty_files_present".to_string());
276 }
277
278 // --- Ledger density ---------------------------------------------------
279 // Clean-room DBs (fresh init / clone) are expected to be sparse.
280 // Don't penalise them for it — the data_quality block already explains the state.
281 let is_clean_room = dq.state == "clean_room";
282 if sig.ledger_density < 0.05 {
283 if is_clean_room {
284 reasons.push("clean_room_sparse_ledger".to_string());
285 } else {
286 score -= 0.15;
287 reasons.push("sparse_ledger".to_string());
288 }
289 } else if sig.ledger_density < 0.50 {
290 score -= 0.05;
291 reasons.push("sparse_ledger".to_string());
292 } else {
293 reasons.push("ledger_annotated".to_string());
294 }
295
296 // --- Concept gaps (as rate relative to symbol count) ------------------
297 let gap_rate = sig.concept_gap_count as f64 / sig.symbol_count.max(1) as f64;
298 if gap_rate > 0.10 {
299 score -= 0.05;
300 reasons.push("concept_gaps_present".to_string());
301 }
302
303 finish(score, reasons, blocking, sig, dq)
304}
305
306fn finish(
307 raw_score: f64,
308 mut reasons: Vec<String>,
309 blocking: bool,
310 sig: &TrustSignals,
311 dq: &DataQuality,
312) -> TrustScore {
313 let score = (raw_score.clamp(0.0, 1.0) * 100.0).round() / 100.0;
314 let is_blocked = blocking || score < 0.40;
315 let level = if is_blocked {
316 "blocked"
317 } else if score >= 0.85 {
318 "high"
319 } else if score >= 0.65 {
320 "medium"
321 } else {
322 "low"
323 };
324 reasons.sort();
325 let (safe_to_use_for, avoid_for) = derive_use_guidance(level, dq, is_blocked);
326 TrustScore {
327 score,
328 level: level.to_string(),
329 reasons,
330 blocking: is_blocked,
331 signals: sig.clone(),
332 data_quality: dq.clone(),
333 safe_to_use_for,
334 avoid_for,
335 }
336}
337
338/// Derive actionable use-guidance from trust level and data-quality state.
339///
340/// Task categories:
341/// - `search` — symbol/code lookup, FTS queries
342/// - `impact` — change-impact analysis (callers/callees, trace)
343/// - `change-planning` — prepare-change, edit-file classification
344/// - `audit` — effect tracking, annotation review, blame
345/// - `compliance` — ledger-based compliance, evidence verification
346fn derive_use_guidance(
347 level: &str,
348 dq: &DataQuality,
349 blocking: bool,
350) -> (Vec<String>, Vec<String>) {
351 // All known categories in a stable order.
352 let all: &[&str] = &["search", "impact", "change-planning", "audit", "compliance"];
353
354 if blocking || dq.state == "empty" {
355 // Nothing is reliable when the index is empty or blocking.
356 return (vec![], all.iter().map(|s| s.to_string()).collect());
357 }
358
359 let (safe, avoid): (Vec<&str>, Vec<&str>) = match (dq.state.as_str(), level) {
360 // Fresh workspace — index is good, ledger is empty by design.
361 // Search / impact / change-planning work; audit/compliance need annotations.
362 ("clean_room" | "unannotated", _) => (
363 vec!["search", "impact", "change-planning"],
364 vec!["audit", "compliance"],
365 ),
366
367 // Regression signal: low confidence across the board.
368 // Only basic search is reasonable; everything else is risky.
369 ("degraded", _) => (
370 vec!["search"],
371 vec!["impact", "change-planning", "audit", "compliance"],
372 ),
373
374 // Partially annotated — most tasks are fine, full compliance isn't yet.
375 ("sparse_but_active", "high" | "medium") => (
376 vec!["search", "impact", "change-planning", "audit"],
377 vec!["compliance"],
378 ),
379 ("sparse_but_active", _) => (
380 vec!["search", "impact", "change-planning"],
381 vec!["audit", "compliance"],
382 ),
383
384 // Healthy, well-annotated workspace.
385 ("populated", "high") => (
386 vec!["search", "impact", "change-planning", "audit", "compliance"],
387 vec![],
388 ),
389 ("populated", "medium") => (
390 vec!["search", "impact", "change-planning", "audit"],
391 vec!["compliance"],
392 ),
393 ("populated", _) => (
394 vec!["search", "impact"],
395 vec!["change-planning", "audit", "compliance"],
396 ),
397
398 // Unknown state — conservative defaults.
399 _ => (
400 vec!["search"],
401 vec!["impact", "change-planning", "audit", "compliance"],
402 ),
403 };
404
405 (
406 safe.iter().map(|s| s.to_string()).collect(),
407 avoid.iter().map(|s| s.to_string()).collect(),
408 )
409}
410
411// ---------------------------------------------------------------------------
412// Data-quality classification
413// ---------------------------------------------------------------------------
414
415/// Classify workspace data quality by combining ledger density with presence of
416/// prior-activity markers in `.asd/` history files.
417///
418/// Prior-activity signals checked (in priority order):
419/// - `.asd/index.log` — written by `asd index` (most reliable)
420/// - `.asd/workflow-sessions.jsonl` — written by `asd task-close`
421/// - `.asd/probe-history.jsonl` — written by `asd probe run`
422/// - `.asd/trust-history.jsonl` — written by `asd status --json` (≥3 entries)
423///
424/// Any of these present implies the workspace was actively used, not freshly cloned.
425fn classify_data_quality(sig: &TrustSignals, project_root: &std::path::Path) -> DataQuality {
426 if sig.symbol_count == 0 {
427 return DataQuality {
428 state: "empty".to_string(),
429 reason: "no symbols indexed — run `asd index` to populate".to_string(),
430 expected_after_reset: false,
431 };
432 }
433
434 let dot_asd = project_root.join(".asd");
435 let has_prior_activity = file_has_content(&dot_asd.join("index.log"))
436 || file_has_content(&dot_asd.join("workflow-sessions.jsonl"))
437 || file_has_content(&dot_asd.join("probe-history.jsonl"))
438 || file_line_count(&dot_asd.join("trust-history.jsonl")) >= 3;
439
440 if sig.ledger_density < 0.05 {
441 // Field-test refinement (AcmeFlow, 2026-06-04): if the live
442 // ledger cache has ANY annotated symbols, we're not "unannotated"
443 // — we're sparse-but-active. The density-only gate misclassified
444 // projects with a handful of entries spread across thousands of
445 // symbols (e.g. 13 entries on 9000 symbols → density 0.0014 →
446 // false `unannotated` label). Promote to sparse_but_active.
447 if sig.annotated_symbol_count.unwrap_or(0) > 0 {
448 return DataQuality {
449 state: "sparse_but_active".to_string(),
450 reason: format!(
451 "{} annotated symbol(s) on a large surface — \
452 coverage is low but annotation is active. \
453 Keep adding entries to lift density.",
454 sig.annotated_symbol_count.unwrap_or(0)
455 ),
456 expected_after_reset: false,
457 };
458 }
459 if !has_prior_activity {
460 // No index, no history → genuinely fresh.
461 DataQuality {
462 state: "clean_room".to_string(),
463 reason: "fresh index with sparse ledger and no prior activity — \
464 expected after `asd init` or `git clone`"
465 .to_string(),
466 expected_after_reset: true,
467 }
468 } else if sig.ledger_density == 0.0 {
469 // Index exists, was run before, but no annotations were ever written.
470 // This is a normal "unannotated" workspace — not alarming.
471 DataQuality {
472 state: "unannotated".to_string(),
473 reason: "index built but no ledger annotations yet — \
474 run `asd annotate-commit` or `asd task-close` to start"
475 .to_string(),
476 expected_after_reset: true,
477 }
478 } else {
479 // Had some entries, now suspiciously few — genuine regression signal.
480 DataQuality {
481 state: "degraded".to_string(),
482 reason: "sparse ledger despite prior task and probe activity — \
483 possible state loss or DB reset"
484 .to_string(),
485 expected_after_reset: false,
486 }
487 }
488 } else if sig.ledger_density < 0.50 {
489 DataQuality {
490 state: "sparse_but_active".to_string(),
491 reason: "partially annotated workspace — annotation in progress".to_string(),
492 expected_after_reset: false,
493 }
494 } else {
495 DataQuality {
496 state: "populated".to_string(),
497 reason: "well-annotated workspace with healthy ledger density".to_string(),
498 expected_after_reset: false,
499 }
500 }
501}
502
503/// Returns true if a file exists and contains at least one non-blank line.
504fn file_has_content(path: &std::path::Path) -> bool {
505 std::fs::read_to_string(path)
506 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
507 .unwrap_or(false)
508}
509
510/// Returns the count of non-blank lines in a file (0 if absent or unreadable).
511fn file_line_count(path: &std::path::Path) -> usize {
512 std::fs::read_to_string(path)
513 .map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
514 .unwrap_or(0)
515}
516
517// ---------------------------------------------------------------------------
518// Helpers
519// ---------------------------------------------------------------------------
520
521/// Count source files that git reports as modified or staged (excludes untracked).
522fn count_dirty_source_files(project_root: &Path) -> usize {
523 const SRC_EXTS: &[&str] = &[
524 ".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs", ".m",
525 ".mm", ".cpp", ".c",
526 ];
527 let out = std::process::Command::new("git")
528 .args(["status", "--short", "--untracked-files=no"])
529 .current_dir(project_root)
530 .output();
531 match out {
532 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
533 .lines()
534 .filter(|l| l.len() > 3 && SRC_EXTS.iter().any(|ext| l.ends_with(ext)))
535 .count(),
536 _ => 0,
537 }
538}
539
540/// Derive ledger density and concept-gap count from the ASG.
541/// Returns (density, gap_count). Falls back to (0.0, 0) if Engine can't open.
542fn ledger_signals(db_path: &Path, _symbol_count: u64) -> (f64, usize) {
543 let engine = match Engine::open_sqlite(db_path) {
544 Ok(e) => e,
545 Err(_) => return (0.0, 0),
546 };
547 let index_store = AsgIndexStore::from_engine(&engine);
548 let ledger_store = AsgLedgerStore::from_engine(&engine);
549
550 // Fast path: if the live ledger cache shows zero annotated symbols,
551 // skip the per-symbol ledger walks entirely (truly unannotated /
552 // clean-room DBs). Reads from `asd_ledger_cache` (live, written by
553 // every `asd think`/`asd ledger append`/`asd annotate-commit`), NOT
554 // from `asd_search_fts.ledger_text` (only populated at full
555 // `asd index` time, stale between reindexes). See
556 // SearchFtsDb::annotated_symbol_count docs for the field-test that
557 // motivated the swap.
558 let annotated = engine
559 .fts
560 .as_ref()
561 .map(|fts| fts.annotated_symbol_count(&engine.ref_name))
562 .unwrap_or(0);
563 if annotated == 0 {
564 return (0.0, 0);
565 }
566
567 // Load all symbols via the SQLite cache (avoids a full git tree walk).
568 let syms: Vec<Symbol> = index_store.build_id_map(&engine).into_values().collect();
569
570 if syms.is_empty() {
571 return (0.0, 0);
572 }
573
574 let mut total_entries = 0usize;
575 let mut concept_gaps = 0usize;
576
577 for sym in &syms {
578 let entries = ledger_store
579 .list_entries(&engine.ref_name, &sym.symbol_id)
580 .unwrap_or_default();
581 total_entries += entries.len();
582 let has_ownership = entries.iter().any(|e| e.kind == LedgerKind::Ownership);
583 let has_concept = entries.iter().any(|e| e.kind == LedgerKind::Concept);
584 if has_ownership && !has_concept {
585 concept_gaps += 1;
586 }
587 }
588
589 let density = total_entries as f64 / syms.len().max(1) as f64;
590 (density, concept_gaps)
591}
592
593#[cfg(test)]
594mod acmeflow_unannotated_fix_tests {
595 //! Field-test refinement (AcmeFlow, 2026-06-04): the
596 //! `unannotated` label was firing on projects with live ledger
597 //! entries because the density gate ignored `asd_ledger_cache`
598 //! and the count gate read from a stale FTS column. Lock the
599 //! new classification.
600
601 use super::*;
602
603 fn mk_signals(density: f64, annotated_count: Option<usize>) -> TrustSignals {
604 TrustSignals {
605 age_hours: 1.0,
606 symbol_count: 9675, // AcmeProj-shaped large repo
607 sidecar_state: "present".into(),
608 dirty_file_count: 0,
609 concept_gap_count: 0,
610 ledger_density: density,
611 annotated_symbol_count: annotated_count,
612 schema_version: ASD_SCHEMA_VERSION.to_string(),
613 }
614 }
615
616 #[test]
617 fn sparse_density_with_live_annotations_classifies_as_sparse_but_active() {
618 // The AcmeFlow scenario: 13 entries on 9675 symbols →
619 // density 0.0013, BUT annotated_symbol_count > 0. Must NOT
620 // be labeled `unannotated`.
621 let sig = mk_signals(0.0013, Some(13));
622 let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
623 assert_eq!(
624 dq.state, "sparse_but_active",
625 "live annotations must promote out of unannotated; got: {dq:#?}"
626 );
627 assert!(
628 dq.reason.contains("13"),
629 "reason should mention the actual count; got: {}",
630 dq.reason
631 );
632 }
633
634 #[test]
635 fn zero_annotations_with_prior_activity_still_unannotated() {
636 // Backward-compat for the legit case: index built, history
637 // exists, but no ledger entries ever written. Stay
638 // `unannotated`. Need has_prior_activity to be true — use
639 // a CARGO_MANIFEST_DIR-based path that has a .asd dir
640 // (the repo root does).
641 let cli_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
642 let repo_root = cli_dir.parent().and_then(|p| p.parent()).unwrap();
643 // .asd/index.log exists in the repo root from prior asd runs.
644 // If not, this test falls into clean_room which is still
645 // semantically correct (no annotations + no history → fresh).
646 let sig = mk_signals(0.0, Some(0));
647 let dq = classify_data_quality(&sig, repo_root);
648 assert!(
649 dq.state == "unannotated" || dq.state == "clean_room",
650 "zero annotations should stay unannotated/clean_room, not sparse_but_active; got: {dq:#?}"
651 );
652 }
653
654 #[test]
655 fn unset_annotated_count_falls_back_to_density_gate() {
656 // Defensive: FTS unavailable → annotated_symbol_count = None.
657 // Must NOT panic, must not promote to sparse_but_active.
658 let sig = mk_signals(0.0, None);
659 let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
660 // /tmp has no prior_activity → clean_room
661 assert_eq!(dq.state, "clean_room");
662 }
663
664 #[test]
665 fn high_density_unaffected_by_annotated_count_path() {
666 // Annotated count is only a fallback for the low-density
667 // case. Density above 0.50 → populated regardless.
668 let sig = mk_signals(0.75, Some(1000));
669 let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
670 assert_eq!(dq.state, "populated");
671 }
672}