Skip to main content

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}