1use std::collections::{HashMap, HashSet};
15
16use serde::Serialize;
17use serde_json::{Value, json};
18
19use crate::candidates::{confidence_scores, explain_match, result_bucket};
20use crate::effects::{AsgEffectStore, EffectStore};
21use crate::engine::Engine;
22use crate::index::{AsgIndexStore, IndexStore};
23use crate::ledger::{AsgLedgerStore, LedgerStore};
24use crate::schema::{ASD_PATH_PREFIX, LedgerEntry, LedgerKind, Symbol};
25use crate::search_fts::{
26 FileRecency, SearchFtsDb, classify_file_role, classify_layer_sym, extract_summary,
27 git_dirty_files, symbol_tier,
28};
29
30#[derive(Debug, Clone, Serialize)]
34pub struct FileScoreEntry {
35 pub score: f64,
36 pub file: String,
37 pub layer: String,
38 pub last_touched_days: Option<f64>,
39 pub hot: bool,
40 pub top_symbol: String,
41 pub why: String,
42}
43
44pub const FILE_SCORE_FLOOR_RATIO: f64 = 0.40;
57
58pub const CLIFF_RATIO_THRESHOLD: f64 = 0.70;
74
75pub fn cliff_cutoff_index<I: IntoIterator<Item = f64>>(scores: I) -> usize {
105 let scores: Vec<f64> = scores.into_iter().collect();
106 if scores.len() < 2 {
107 return scores.len();
108 }
109 for i in 0..scores.len() - 1 {
110 let prev = scores[i];
111 let next = scores[i + 1];
112 if prev <= 0.0 {
113 return i + 1;
114 }
115 if next / prev < CLIFF_RATIO_THRESHOLD {
116 return i + 1; }
118 }
119 scores.len()
120}
121
122pub fn file_score_floor(candidates: &[(f64, String)]) -> f64 {
123 let Some((top, _)) = candidates.first() else {
124 return 0.0;
125 };
126 let relative_floor = top * FILE_SCORE_FLOOR_RATIO;
127
128 let mut cliff_floor = relative_floor;
133 for window in candidates.windows(2) {
134 let prev = window[0].0;
135 let next = window[1].0;
136 if prev <= 0.0 {
137 break;
138 }
139 if next / prev < CLIFF_RATIO_THRESHOLD {
140 cliff_floor = prev;
141 break;
142 }
143 }
144 relative_floor.max(cliff_floor)
145}
146
147pub fn push_file_score(
153 out: &mut Vec<FileScoreEntry>,
154 seen_files: &mut std::collections::HashSet<String>,
155 sym: &Symbol,
156 entries: &[LedgerEntry],
157 tokens: &[String],
158 score: f64,
159 layer: &str,
160 last_touched_days: Option<f64>,
161 hot: bool,
162 floor: f64,
163) -> bool {
164 if score < floor {
165 return false;
166 }
167 if !seen_files.insert(sym.file.clone()) {
168 return false;
169 }
170 let reasons = explain_match(sym, tokens, entries, hot);
171 let why = reasons
172 .first()
173 .cloned()
174 .unwrap_or_else(|| format!("contains symbol {}", sym.qname));
175 out.push(FileScoreEntry {
176 score,
177 file: sym.file.clone(),
178 layer: layer.to_string(),
179 last_touched_days,
180 hot,
181 top_symbol: sym.qname.clone(),
182 why,
183 });
184 true
185}
186
187pub type FileScoreTuple = (f64, String, String, Option<f64>, bool, String, String);
202
203#[derive(Debug, Default)]
208pub struct CandidateAggregates {
209 pub by_layer: serde_json::Map<String, Value>,
210 pub design_invariants: Vec<Value>,
211 pub known_hazards: Vec<Value>,
212 pub validation_scenarios_ledger: Vec<Value>,
213 pub effects_summary: Vec<Value>,
214 pub file_scores: Vec<FileScoreTuple>,
215 pub top_sym_id: Option<String>,
216 pub seen_inv: HashSet<String>,
217}
218
219pub fn aggregate_candidate_data(
232 engine: &Engine,
233 index_store: &AsgIndexStore<'_>,
234 ledger_store: &AsgLedgerStore<'_>,
235 effect_store: &AsgEffectStore<'_>,
236 candidates: &[(f64, String)],
237 tokens: &[String],
238 recency: &HashMap<String, FileRecency>,
239 layer_overrides: &[(String, String)],
240) -> CandidateAggregates {
241 let raw_scores: Vec<f64> = candidates.iter().map(|(s, _)| *s).collect();
242 let confidences = confidence_scores(&raw_scores);
243 let mut by_layer: serde_json::Map<String, Value> = serde_json::Map::new();
244 let mut design_invariants: Vec<Value> = Vec::new();
245 let mut known_hazards: Vec<Value> = Vec::new();
246 let mut validation_scenarios_ledger: Vec<Value> = Vec::new();
247 let mut effects_summary: Vec<Value> = Vec::new();
248 let mut seen_inv: HashSet<String> = HashSet::new();
249 let mut seen_vs: HashSet<String> = HashSet::new();
250 let mut seen_effect: HashSet<String> = HashSet::new();
251 let effect_score_floor = candidates.first().map(|(s, _)| s * 0.25).unwrap_or(0.0);
253
254 let mut file_scores: Vec<FileScoreTuple> = Vec::new();
257 let mut seen_files: HashSet<String> = HashSet::new();
258 let file_score_floor = file_score_floor(candidates);
259
260 let mut top_sym_id: Option<String> = None;
261
262 for (idx, (score, qname)) in candidates.iter().enumerate() {
263 let conf = confidences.get(idx).copied().unwrap_or(0.5);
264 let sym = match index_store.get_symbol_by_qname(&engine.ref_name, qname) {
265 Ok(Some(s)) => s,
266 _ => continue,
267 };
268 let tier = symbol_tier(&sym.file);
269 let layer = classify_layer_sym(&sym.file, &sym.qname, tier, layer_overrides);
270 let summary = extract_summary(sym.doc.as_deref(), sym.signature.as_deref());
271 let rec = recency.get(&sym.file);
272 let last_touched_days = rec.and_then(|r| r.last_touched_days);
273 let hot = rec.map(|r| r.hot).unwrap_or(false);
274
275 if top_sym_id.is_none() {
276 top_sym_id = Some(sym.symbol_id.clone());
277 }
278
279 let entries = ledger_store
280 .list_entries(&engine.ref_name, &sym.symbol_id)
281 .unwrap_or_default();
282
283 if seen_files.insert(sym.file.clone()) && *score >= file_score_floor {
286 let reasons = explain_match(&sym, tokens, &entries, hot);
287 let why = reasons
288 .first()
289 .cloned()
290 .unwrap_or_else(|| format!("contains symbol {}", sym.qname));
291 file_scores.push((
292 *score,
293 sym.file.clone(),
294 layer.to_string(),
295 last_touched_days,
296 hot,
297 sym.qname.clone(),
298 why,
299 ));
300 }
301 for entry in &entries {
302 let key = entry.summary.clone();
303 match entry.kind {
304 LedgerKind::Invariant => {
305 if seen_inv.insert(key) {
306 design_invariants.push(json!({
310 "entry_id": entry.entry_id,
311 "summary": entry.summary,
312 "source": sym.qname,
313 }));
314 }
315 }
316 LedgerKind::Hazard => {
317 known_hazards.push(json!({
318 "summary": entry.summary,
319 "source": sym.qname,
320 }));
321 }
322 LedgerKind::ValidationScenario => {
323 if seen_vs.insert(key) {
324 validation_scenarios_ledger.push(json!({
325 "scenario": entry.summary,
326 "source": sym.qname,
327 }));
328 }
329 }
330 _ => {}
331 }
332 }
333
334 if *score >= effect_score_floor {
338 if let Ok(Some(decl)) = effect_store.get_effects(&engine.ref_name, &sym.symbol_id) {
339 let has_high_signal = decl.declared.iter().any(|e| !e.effect.is_low_signal());
340 for eff in &decl.declared {
341 if has_high_signal && eff.effect.is_low_signal() {
342 continue;
343 }
344 let cat = eff.effect.as_str().to_string();
345 let key = format!("{}:{}", cat, sym.qname);
346 if seen_effect.insert(key) {
347 effects_summary.push(json!({
348 "category": cat,
349 "source": sym.qname,
350 }));
351 }
352 }
353 }
354 }
355
356 let has_ledger = !entries.is_empty();
358 let match_reasons = explain_match(&sym, tokens, &entries, hot);
359 let bucket = result_bucket(&sym.file, &match_reasons, has_ledger, hot);
360 let ep_val = json!({
361 "score": score,
362 "confidence": conf,
363 "bucket": bucket,
364 "match_reasons": match_reasons,
365 "qname": sym.qname,
366 "file": sym.file,
367 "line": sym.start.line,
368 "layer": layer,
369 "summary": summary,
370 "last_touched_days": last_touched_days,
371 "hot": hot,
372 });
373 by_layer
374 .entry(layer.to_string())
375 .or_insert_with(|| Value::Array(vec![]))
376 .as_array_mut()
377 .unwrap()
378 .push(ep_val);
379 }
380
381 CandidateAggregates {
382 by_layer,
383 design_invariants,
384 known_hazards,
385 validation_scenarios_ledger,
386 effects_summary,
387 file_scores,
388 top_sym_id,
389 seen_inv,
390 }
391}
392
393pub fn propagate_caller_invariants(
406 engine: &Engine,
407 index_store: &AsgIndexStore<'_>,
408 ledger_store: &AsgLedgerStore<'_>,
409 db_path: &std::path::Path,
410 candidates: &[(f64, String)],
411 seen_inv: &mut HashSet<String>,
412) -> Vec<Value> {
413 let mut out: Vec<Value> = Vec::new();
414 let mut candidate_sym_ids: Vec<(String, String)> = Vec::new();
415 for (_, qname) in candidates.iter() {
416 if let Ok(Some(s)) = index_store.get_symbol_by_qname(&engine.ref_name, qname) {
417 candidate_sym_ids.push((s.symbol_id, s.qname));
418 }
419 }
420 let mut caller_ids_seen: HashSet<String> = HashSet::new();
421 let mut caller_visit_order: Vec<(String, String)> = Vec::new();
422 for (cand_sym_id, _) in &candidate_sym_ids {
423 let direct_callers = index_store
424 .get_callers(&engine.ref_name, cand_sym_id)
425 .unwrap_or_default();
426 for caller_id in direct_callers {
427 if candidate_sym_ids.iter().any(|(sid, _)| sid == &caller_id) {
428 continue;
429 }
430 if caller_ids_seen.insert(caller_id.clone()) {
431 caller_visit_order.push((cand_sym_id.clone(), caller_id));
432 }
433 }
434 }
435 let caller_id_strs: Vec<&str> = caller_visit_order
436 .iter()
437 .map(|(_, cid)| cid.as_str())
438 .collect();
439 let caller_resolved = SearchFtsDb::open(db_path)
440 .ok()
441 .map(|fts| fts.resolve_symbol_ids_bulk(&caller_id_strs))
442 .unwrap_or_default();
443 let need_fallback = caller_visit_order
444 .iter()
445 .any(|(_, cid)| !caller_resolved.contains_key(cid.as_str()));
446 let fallback_id_to_qname: HashMap<String, String> = if need_fallback {
447 let prefix = format!("{}/index/by-qname", ASD_PATH_PREFIX);
448 match engine.repo.get_tree(&engine.ref_name, &prefix) {
449 Ok(Value::Object(m)) => m
450 .into_iter()
451 .filter_map(|(qn, v)| {
452 v.get("symbol_id")
453 .and_then(|v| v.as_str())
454 .map(|sid| (sid.to_string(), qn))
455 })
456 .collect(),
457 _ => HashMap::new(),
458 }
459 } else {
460 HashMap::new()
461 };
462 for (_cand_sym_id, caller_id) in &caller_visit_order {
463 let caller_qname = caller_resolved
464 .get(caller_id.as_str())
465 .map(|r| r.qname.as_str())
466 .or_else(|| fallback_id_to_qname.get(caller_id).map(String::as_str));
467 let Some(caller_qname) = caller_qname else {
468 continue;
469 };
470 let caller_entries = ledger_store
471 .list_entries(&engine.ref_name, caller_id)
472 .unwrap_or_default();
473 for entry in caller_entries {
474 if !matches!(entry.kind, LedgerKind::Invariant) {
475 continue;
476 }
477 let key = entry.summary.clone();
478 if seen_inv.insert(key) {
479 out.push(json!({
480 "entry_id": entry.entry_id,
481 "summary": entry.summary,
482 "source": caller_qname,
483 "from_caller": true,
484 }));
485 }
486 }
487 }
488 out
489}
490
491pub fn explain_conflict_risk(file: &str) -> Option<String> {
499 if let Ok(content) = std::fs::read_to_string(file) {
500 if content.contains("<<<<<<<") {
501 return Some("file contains unresolved merge conflict markers".to_string());
502 }
503 }
504 let status = std::process::Command::new("git")
505 .args(["status", "--porcelain", "--", file])
506 .output()
507 .ok()?;
508 let status_str = String::from_utf8_lossy(&status.stdout);
509 let code = status_str.chars().take(2).collect::<String>();
510 let reason = match code.trim() {
511 "M" | "MM" => "has unstaged modifications",
512 "A" => "is newly staged",
513 "D" => "is staged for deletion",
514 "R" => "has been renamed (staged)",
515 "UU" => "has unmerged changes",
516 s if s.contains('M') => "has staged and/or unstaged modifications",
517 _ => "has uncommitted changes",
518 };
519 Some(reason.to_string())
520}
521
522pub fn finalize_file_scores(
536 file_scores: &mut Vec<FileScoreTuple>,
537 dirty_files: &HashSet<String>,
538 include_conflict_detail: bool,
539) -> Vec<Value> {
540 file_scores.sort_by(|a, b| {
541 b.4.cmp(&a.4)
542 .then_with(|| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal))
543 });
544 let mut score_only_sorted: Vec<f64> = file_scores.iter().map(|f| f.0).collect();
545 score_only_sorted.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
546 let cliff_cut = cliff_cutoff_index(score_only_sorted.iter().copied());
547 if cliff_cut < file_scores.len() {
548 let cutoff_score = score_only_sorted[cliff_cut - 1];
549 file_scores.retain(|f| f.0 >= cutoff_score);
550 }
551 file_scores
552 .iter()
553 .map(|(score, file, layer, days, hot, top_symbol, why)| {
554 let file_role = classify_file_role(file);
555 let conflict_risk = dirty_files.contains(file.as_str());
556 let conflict_detail = if include_conflict_detail && conflict_risk {
557 explain_conflict_risk(file)
558 } else {
559 None
560 };
561 json!({
562 "file": file,
563 "layer": layer,
564 "score": score,
565 "last_touched_days": days,
566 "hot": hot,
567 "file_role": file_role,
568 "conflict_risk": conflict_risk,
569 "conflict_detail": conflict_detail,
570 "top_symbol": top_symbol,
571 "why": why,
572 })
573 })
574 .collect()
575}
576
577pub fn dirty_files_for_change() -> HashSet<String> {
582 git_dirty_files()
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::schema::{
589 Author, AuthorKind, LedgerEntry, LedgerKind, Position, Symbol, SymbolKind,
590 };
591 use std::collections::HashSet;
592
593 fn sym(qname: &str, file: &str) -> Symbol {
594 Symbol {
595 symbol_id: format!("sym_{qname}"),
596 symbol_fp: "fp".into(),
597 qname: qname.into(),
598 language: "python".into(),
599 kind: SymbolKind::Function,
600 file: file.into(),
601 start: Position { line: 1, col: 0 },
602 end: Position { line: 5, col: 0 },
603 signature: Some(format!("def {qname}()")),
604 doc: None,
605 }
606 }
607
608 #[test]
609 fn file_score_floor_cliff_at_huge_drop() {
610 let cands = vec![(40.0_f64, "a".into()), (5.0, "b".into())];
614 let floor = file_score_floor(&cands);
615 assert!((floor - 40.0).abs() < 1e-9, "cliff at rank 1; got {floor}");
616 }
617
618 #[test]
619 fn file_score_floor_empty_input_is_zero() {
620 assert_eq!(file_score_floor(&[]), 0.0);
621 }
622
623 #[test]
624 fn file_score_floor_ratio_is_40pct() {
625 assert!((FILE_SCORE_FLOOR_RATIO - 0.40).abs() < 1e-9);
626 }
627
628 #[test]
629 fn file_score_floor_cliff_ratio_is_70pct() {
630 assert!((CLIFF_RATIO_THRESHOLD - 0.70).abs() < 1e-9);
632 }
633
634 #[test]
635 fn file_score_floor_cliff_cuts_at_cohort_boundary() {
636 let cands = vec![
643 (42.0_f64, "drift_view_model".into()),
644 (31.0, "drift_app".into()),
645 (19.0, "project_view_model".into()),
646 (18.0, "project_controller".into()),
647 ];
648 let floor = file_score_floor(&cands);
649 assert!(
650 (floor - 31.0).abs() < 1e-9,
651 "cliff-aware floor must lock to pre-cliff rank score (31); got {floor}"
652 );
653 }
654
655 #[test]
656 fn file_score_floor_targeted_query_with_clear_winner() {
657 let cands = vec![
660 (80.0_f64, "winner".into()),
661 (20.0, "also_ran".into()),
662 (15.0, "noise".into()),
663 ];
664 let floor = file_score_floor(&cands);
665 assert!((floor - 80.0).abs() < 1e-9, "got {floor}");
666 }
667
668 #[test]
669 fn file_score_floor_no_cliff_falls_back_to_relative() {
670 let cands = vec![
679 (100.0_f64, "a".into()),
680 (90.0, "b".into()),
681 (80.0, "c".into()),
682 (75.0, "d".into()),
683 ];
684 let floor = file_score_floor(&cands);
685 assert!((floor - 40.0).abs() < 1e-9, "got {floor}");
686 }
687
688 #[test]
689 fn file_score_floor_cliff_after_smooth_decay_stops_there() {
690 let cands = vec![
696 (100.0_f64, "a".into()),
697 (90.0, "b".into()),
698 (80.0, "c".into()),
699 (70.0, "d".into()),
700 (30.0, "noise".into()),
701 ];
702 let floor = file_score_floor(&cands);
703 assert!((floor - 70.0).abs() < 1e-9, "cliff floor=70; got {floor}");
704 }
705
706 #[test]
707 fn cliff_cutoff_index_handles_short_lists() {
708 assert_eq!(cliff_cutoff_index(std::iter::empty::<f64>()), 0);
709 assert_eq!(cliff_cutoff_index(vec![42.0]), 1);
710 }
711
712 #[test]
713 fn cliff_cutoff_index_matches_acmeflow_file_scores() {
714 let cut = cliff_cutoff_index(vec![42.0, 31.0, 19.0, 18.0]);
718 assert_eq!(cut, 2, "must keep top 2; got {cut}");
719 }
720
721 #[test]
722 fn cliff_cutoff_index_smooth_keeps_everything() {
723 let cut = cliff_cutoff_index(vec![100.0, 90.0, 80.0, 75.0]);
725 assert_eq!(cut, 4);
726 }
727
728 #[test]
729 fn cliff_cutoff_index_zero_prev_bails_safely() {
730 let cut = cliff_cutoff_index(vec![10.0, 0.0, 5.0]);
731 assert_eq!(cut, 1, "cliff at zero prev → keep through index 0");
732 }
733
734 #[test]
735 fn file_score_floor_cliff_at_first_pair_triggers() {
736 let cands = vec![
740 (100.0_f64, "winner".into()),
741 (60.0, "after_cliff".into()), ];
743 let floor = file_score_floor(&cands);
744 assert!((floor - 100.0).abs() < 1e-9, "got {floor}");
746 }
747
748 #[test]
749 fn push_file_score_admits_above_floor_and_unseen() {
750 let mut out = Vec::new();
751 let mut seen: HashSet<String> = HashSet::new();
752 let pushed = push_file_score(
753 &mut out,
754 &mut seen,
755 &sym("a.b", "x.py"),
756 &[],
757 &["b".into()],
758 20.0,
759 "app",
760 Some(3.0),
761 true,
762 10.0,
763 );
764 assert!(pushed);
765 assert_eq!(out.len(), 1);
766 assert_eq!(out[0].file, "x.py");
767 assert_eq!(out[0].top_symbol, "a.b");
768 assert!(out[0].why.starts_with("contains symbol") || !out[0].why.is_empty());
769 }
770
771 #[test]
772 fn push_file_score_drops_below_floor() {
773 let mut out = Vec::new();
774 let mut seen: HashSet<String> = HashSet::new();
775 let pushed = push_file_score(
776 &mut out,
777 &mut seen,
778 &sym("a.b", "x.py"),
779 &[],
780 &[],
781 5.0,
782 "app",
783 None,
784 false,
785 10.0,
786 );
787 assert!(!pushed);
788 assert!(out.is_empty());
789 }
790
791 #[test]
792 fn push_file_score_dedups_by_file() {
793 let mut out = Vec::new();
794 let mut seen: HashSet<String> = HashSet::new();
795 push_file_score(
796 &mut out,
797 &mut seen,
798 &sym("a.b", "x.py"),
799 &[],
800 &[],
801 20.0,
802 "app",
803 None,
804 false,
805 10.0,
806 );
807 let second = push_file_score(
808 &mut out,
809 &mut seen,
810 &sym("a.c", "x.py"),
811 &[],
812 &[],
813 20.0,
814 "app",
815 None,
816 false,
817 10.0,
818 );
819 assert!(!second, "duplicate file must not push a second entry");
820 assert_eq!(out.len(), 1);
821 }
822
823 #[test]
824 fn push_file_score_carries_ledger_reason_when_available() {
825 let mut out = Vec::new();
826 let mut seen: HashSet<String> = HashSet::new();
827 let entry = LedgerEntry::new(
828 "sym_x",
829 LedgerKind::Hazard,
830 "watch out",
831 Author {
832 kind: AuthorKind::Agent,
833 id: "t".into(),
834 },
835 );
836 push_file_score(
837 &mut out,
838 &mut seen,
839 &sym("a.b", "x.py"),
840 std::slice::from_ref(&entry),
841 &[],
842 20.0,
843 "app",
844 None,
845 false,
846 0.0,
847 );
848 assert!(!out[0].why.is_empty());
851 }
852}