use std::path::Path;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
ASD_SCHEMA_VERSION, AsgIndexStore, AsgLedgerStore, Engine, LedgerStore, SearchFtsDb,
SidecarState,
schema::{LedgerKind, Symbol},
sidecar_lifecycle_state,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustSignals {
pub age_hours: f64,
pub symbol_count: u64,
pub sidecar_state: String,
pub dirty_file_count: usize,
pub concept_gap_count: usize,
pub ledger_density: f64,
pub annotated_symbol_count: Option<usize>,
pub schema_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataQuality {
pub state: String,
pub reason: String,
pub expected_after_reset: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustScore {
pub score: f64,
pub level: String,
pub reasons: Vec<String>,
pub blocking: bool,
pub signals: TrustSignals,
pub data_quality: DataQuality,
pub safe_to_use_for: Vec<String>,
pub avoid_for: Vec<String>,
}
impl TrustScore {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub fn compute_trust_score(db_path: &Path) -> TrustScore {
let project_root = db_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let (age_hours, symbol_count) = match SearchFtsDb::open(db_path) {
Ok(fts) => {
let sym = fts.symbol_count() as u64;
let age = match fts.last_indexed_at() {
Some(ts) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let secs = (now - ts).max(0) as f64;
secs / 3600.0
}
None => -1.0,
};
(age, sym)
}
Err(_) => (-1.0, 0),
};
let sidecar_state = sidecar_lifecycle_state(&project_root);
let sidecar_key = match &sidecar_state {
SidecarState::Missing => "missing",
SidecarState::Present => "present",
SidecarState::Hydrated => "hydrated",
SidecarState::FreshReset => "fresh-reset",
};
let dirty_file_count = count_dirty_source_files(&project_root);
let (ledger_density, concept_gap_count) = ledger_signals(db_path, symbol_count);
let annotated_symbol_count = SearchFtsDb::open(db_path).ok().map(|fts| {
let ref_name = Engine::open_sqlite(db_path)
.map(|e| e.ref_name)
.unwrap_or_else(|_| "refs/heads/main".to_string());
fts.annotated_symbol_count(&ref_name)
});
let schema_version = ASD_SCHEMA_VERSION.to_string();
let signals = TrustSignals {
age_hours: (age_hours * 100.0).round() / 100.0,
symbol_count,
sidecar_state: sidecar_key.to_string(),
dirty_file_count,
concept_gap_count,
ledger_density: (ledger_density * 1000.0).round() / 1000.0,
annotated_symbol_count,
schema_version,
};
let data_quality = classify_data_quality(&signals, &project_root);
score_signals(&signals, sidecar_key, &data_quality)
}
fn score_signals(sig: &TrustSignals, sidecar_key: &str, dq: &DataQuality) -> TrustScore {
let mut score: f64 = 1.0;
let mut reasons: Vec<String> = Vec::new();
let mut blocking = false;
if sig.symbol_count == 0 {
score = 0.0;
blocking = true;
reasons.push("empty_index".to_string());
return finish(score, reasons, blocking, sig, dq);
}
if sig.age_hours < 0.0 {
score -= 0.10;
reasons.push("index_age_unknown".to_string());
} else if sig.age_hours < 1.0 {
reasons.push("fresh_index".to_string());
} else if sig.age_hours < 24.0 {
score -= 0.15;
reasons.push("stale_index".to_string());
} else {
score -= 0.30;
reasons.push("very_stale_index".to_string());
}
match sidecar_key {
"hydrated" => {
reasons.push("sidecar_hydrated".to_string());
}
"present" => {
score -= 0.05;
reasons.push("sidecar_present".to_string());
}
"fresh-reset" => {
score -= 0.10;
reasons.push("sidecar_fresh_reset".to_string());
}
_ => {
score -= 0.10;
reasons.push("sidecar_missing".to_string());
}
}
if sig.dirty_file_count > 0 {
score -= 0.05;
reasons.push("dirty_files_present".to_string());
}
let is_clean_room = dq.state == "clean_room";
if sig.ledger_density < 0.05 {
if is_clean_room {
reasons.push("clean_room_sparse_ledger".to_string());
} else {
score -= 0.15;
reasons.push("sparse_ledger".to_string());
}
} else if sig.ledger_density < 0.50 {
score -= 0.05;
reasons.push("sparse_ledger".to_string());
} else {
reasons.push("ledger_annotated".to_string());
}
let gap_rate = sig.concept_gap_count as f64 / sig.symbol_count.max(1) as f64;
if gap_rate > 0.10 {
score -= 0.05;
reasons.push("concept_gaps_present".to_string());
}
finish(score, reasons, blocking, sig, dq)
}
fn finish(
raw_score: f64,
mut reasons: Vec<String>,
blocking: bool,
sig: &TrustSignals,
dq: &DataQuality,
) -> TrustScore {
let score = (raw_score.clamp(0.0, 1.0) * 100.0).round() / 100.0;
let is_blocked = blocking || score < 0.40;
let level = if is_blocked {
"blocked"
} else if score >= 0.85 {
"high"
} else if score >= 0.65 {
"medium"
} else {
"low"
};
reasons.sort();
let (safe_to_use_for, avoid_for) = derive_use_guidance(level, dq, is_blocked);
TrustScore {
score,
level: level.to_string(),
reasons,
blocking: is_blocked,
signals: sig.clone(),
data_quality: dq.clone(),
safe_to_use_for,
avoid_for,
}
}
fn derive_use_guidance(
level: &str,
dq: &DataQuality,
blocking: bool,
) -> (Vec<String>, Vec<String>) {
let all: &[&str] = &["search", "impact", "change-planning", "audit", "compliance"];
if blocking || dq.state == "empty" {
return (vec![], all.iter().map(|s| s.to_string()).collect());
}
let (safe, avoid): (Vec<&str>, Vec<&str>) = match (dq.state.as_str(), level) {
("clean_room" | "unannotated", _) => (
vec!["search", "impact", "change-planning"],
vec!["audit", "compliance"],
),
("degraded", _) => (
vec!["search"],
vec!["impact", "change-planning", "audit", "compliance"],
),
("sparse_but_active", "high" | "medium") => (
vec!["search", "impact", "change-planning", "audit"],
vec!["compliance"],
),
("sparse_but_active", _) => (
vec!["search", "impact", "change-planning"],
vec!["audit", "compliance"],
),
("populated", "high") => (
vec!["search", "impact", "change-planning", "audit", "compliance"],
vec![],
),
("populated", "medium") => (
vec!["search", "impact", "change-planning", "audit"],
vec!["compliance"],
),
("populated", _) => (
vec!["search", "impact"],
vec!["change-planning", "audit", "compliance"],
),
_ => (
vec!["search"],
vec!["impact", "change-planning", "audit", "compliance"],
),
};
(
safe.iter().map(|s| s.to_string()).collect(),
avoid.iter().map(|s| s.to_string()).collect(),
)
}
fn classify_data_quality(sig: &TrustSignals, project_root: &std::path::Path) -> DataQuality {
if sig.symbol_count == 0 {
return DataQuality {
state: "empty".to_string(),
reason: "no symbols indexed — run `asd index` to populate".to_string(),
expected_after_reset: false,
};
}
let dot_asd = project_root.join(".asd");
let has_prior_activity = file_has_content(&dot_asd.join("index.log"))
|| file_has_content(&dot_asd.join("workflow-sessions.jsonl"))
|| file_has_content(&dot_asd.join("probe-history.jsonl"))
|| file_line_count(&dot_asd.join("trust-history.jsonl")) >= 3;
if sig.ledger_density < 0.05 {
if sig.annotated_symbol_count.unwrap_or(0) > 0 {
return DataQuality {
state: "sparse_but_active".to_string(),
reason: format!(
"{} annotated symbol(s) on a large surface — \
coverage is low but annotation is active. \
Keep adding entries to lift density.",
sig.annotated_symbol_count.unwrap_or(0)
),
expected_after_reset: false,
};
}
if !has_prior_activity {
DataQuality {
state: "clean_room".to_string(),
reason: "fresh index with sparse ledger and no prior activity — \
expected after `asd init` or `git clone`"
.to_string(),
expected_after_reset: true,
}
} else if sig.ledger_density == 0.0 {
DataQuality {
state: "unannotated".to_string(),
reason: "index built but no ledger annotations yet — \
run `asd annotate-commit` or `asd task-close` to start"
.to_string(),
expected_after_reset: true,
}
} else {
DataQuality {
state: "degraded".to_string(),
reason: "sparse ledger despite prior task and probe activity — \
possible state loss or DB reset"
.to_string(),
expected_after_reset: false,
}
}
} else if sig.ledger_density < 0.50 {
DataQuality {
state: "sparse_but_active".to_string(),
reason: "partially annotated workspace — annotation in progress".to_string(),
expected_after_reset: false,
}
} else {
DataQuality {
state: "populated".to_string(),
reason: "well-annotated workspace with healthy ledger density".to_string(),
expected_after_reset: false,
}
}
}
fn file_has_content(path: &std::path::Path) -> bool {
std::fs::read_to_string(path)
.map(|s| s.lines().any(|l| !l.trim().is_empty()))
.unwrap_or(false)
}
fn file_line_count(path: &std::path::Path) -> usize {
std::fs::read_to_string(path)
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
.unwrap_or(0)
}
fn count_dirty_source_files(project_root: &Path) -> usize {
const SRC_EXTS: &[&str] = &[
".swift", ".py", ".ts", ".tsx", ".js", ".rs", ".go", ".kt", ".java", ".rb", ".cs", ".m",
".mm", ".cpp", ".c",
];
let out = std::process::Command::new("git")
.args(["status", "--short", "--untracked-files=no"])
.current_dir(project_root)
.output();
match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| l.len() > 3 && SRC_EXTS.iter().any(|ext| l.ends_with(ext)))
.count(),
_ => 0,
}
}
fn ledger_signals(db_path: &Path, _symbol_count: u64) -> (f64, usize) {
let engine = match Engine::open_sqlite(db_path) {
Ok(e) => e,
Err(_) => return (0.0, 0),
};
let index_store = AsgIndexStore::from_engine(&engine);
let ledger_store = AsgLedgerStore::from_engine(&engine);
let annotated = engine
.fts
.as_ref()
.map(|fts| fts.annotated_symbol_count(&engine.ref_name))
.unwrap_or(0);
if annotated == 0 {
return (0.0, 0);
}
let syms: Vec<Symbol> = index_store.build_id_map(&engine).into_values().collect();
if syms.is_empty() {
return (0.0, 0);
}
let mut total_entries = 0usize;
let mut concept_gaps = 0usize;
for sym in &syms {
let entries = ledger_store
.list_entries(&engine.ref_name, &sym.symbol_id)
.unwrap_or_default();
total_entries += entries.len();
let has_ownership = entries.iter().any(|e| e.kind == LedgerKind::Ownership);
let has_concept = entries.iter().any(|e| e.kind == LedgerKind::Concept);
if has_ownership && !has_concept {
concept_gaps += 1;
}
}
let density = total_entries as f64 / syms.len().max(1) as f64;
(density, concept_gaps)
}
#[cfg(test)]
mod acmeflow_unannotated_fix_tests {
use super::*;
fn mk_signals(density: f64, annotated_count: Option<usize>) -> TrustSignals {
TrustSignals {
age_hours: 1.0,
symbol_count: 9675, sidecar_state: "present".into(),
dirty_file_count: 0,
concept_gap_count: 0,
ledger_density: density,
annotated_symbol_count: annotated_count,
schema_version: ASD_SCHEMA_VERSION.to_string(),
}
}
#[test]
fn sparse_density_with_live_annotations_classifies_as_sparse_but_active() {
let sig = mk_signals(0.0013, Some(13));
let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
assert_eq!(
dq.state, "sparse_but_active",
"live annotations must promote out of unannotated; got: {dq:#?}"
);
assert!(
dq.reason.contains("13"),
"reason should mention the actual count; got: {}",
dq.reason
);
}
#[test]
fn zero_annotations_with_prior_activity_still_unannotated() {
let cli_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = cli_dir.parent().and_then(|p| p.parent()).unwrap();
let sig = mk_signals(0.0, Some(0));
let dq = classify_data_quality(&sig, repo_root);
assert!(
dq.state == "unannotated" || dq.state == "clean_room",
"zero annotations should stay unannotated/clean_room, not sparse_but_active; got: {dq:#?}"
);
}
#[test]
fn unset_annotated_count_falls_back_to_density_gate() {
let sig = mk_signals(0.0, None);
let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
assert_eq!(dq.state, "clean_room");
}
#[test]
fn high_density_unaffected_by_annotated_count_path() {
let sig = mk_signals(0.75, Some(1000));
let dq = classify_data_quality(&sig, std::path::Path::new("/tmp/nonexistent"));
assert_eq!(dq.state, "populated");
}
}