Skip to main content

agentstatedeveloper_core/
prepare_change.rs

1//! Plan E t-006: shared helpers for the prepare-change pipeline.
2//!
3//! Background: the full prepare-change orchestration is duplicated across
4//! `agentstatedeveloper-cli/src/commands/prepare_change.rs` and the
5//! `prepare_change` handler in `agentstatedeveloper-mcp/src/mcp_server.rs`.
6//! Plan A t-009 added file_score_floor + top_symbol/why rationale and had
7//! to edit BOTH sites with parallel destructure-tuple updates.
8//!
9//! This module single-sources the t-009 logic so future edits to file
10//! scoring land once. The remaining orchestration (by_layer assembly,
11//! recipe_inspect/edit/run lists) stays per-surface — Plan F captures the
12//! full extract as a follow-up when that duplication becomes painful.
13
14use 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/// Plan A t-009 / Plan E t-006: per-file score entry built during the
31/// prepare-change candidate walk. Carries everything the JSON output
32/// needs without forcing the caller to re-derive layer/recency/why.
33#[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
44/// Relative score floor for file inclusion in `likely_edit_files`.
45///
46/// AcmeFlow field-eval (2026-06-04, refinement #2): the prior 0.25
47/// floor (25% of top score) was too permissive on targeted queries.
48/// AcmeProj observation: a query like "ProjectManager save logic" with
49/// top BM25 ~10 would admit files scoring 2.5+, sweeping in
50/// project/session/UI files that share no query tokens but had hotness
51/// boost from recent edits. Raising to 0.40 cuts that noise while
52/// keeping legitimate secondary results on broader queries (the top
53/// candidate has to be substantially better than the cutoff for the
54/// cutoff to matter — broad queries with several near-equal candidates
55/// are unaffected because the floor scales with the top score).
56pub const FILE_SCORE_FLOOR_RATIO: f64 = 0.40;
57
58/// Cliff detection threshold (1.0.85, AcmeFlow refinement #2 deep
59/// half): when a consecutive score ratio drops below this, the lower
60/// score is treated as the start of a new "also-ran" cohort and the
61/// floor is raised to exclude it.
62///
63/// AcmeFlow case: query "Drift Pad scheduler sync" produced
64/// scores 42 / 31 / 19 / 18. Ratios: 31/42=0.74, 19/31=0.61, 18/19=0.95.
65/// The 19/31 ratio (0.61 < 0.70) marks the cliff between cohort 1
66/// (genuine matches at 42 and 31) and cohort 2 (path-name false
67/// matches at 19 and 18). Floor set to 31 → 2 files survive instead
68/// of 4.
69///
70/// 0.70 picked empirically: queries with smoothly-decaying scores
71/// (e.g. 100 / 90 / 80 / 70) have all consecutive ratios > 0.7 and
72/// are NOT cliff-cut. Only sharp drops trigger.
73pub const CLIFF_RATIO_THRESHOLD: f64 = 0.70;
74
75/// Compute the precision floor for file scoring. Two-pass:
76///   1. Relative floor: 40% of the top candidate's score.
77///   2. Cliff-aware floor: walk consecutive ranks looking for a sharp
78///      drop (ratio < 0.70). When found, raise the floor to the last
79///      pre-cliff score so the also-ran cohort is excluded.
80///
81/// Files scoring below the final floor are dropped from
82/// `likely_edit_files`. Returns 0.0 when `candidates` is empty.
83///
84/// The cliff pass is conservative: it triggers only on sharp drops,
85/// leaving smooth-decay queries (where every rank is a legitimate
86/// secondary hit) unaffected. Field-eval rationale in
87/// `CLIFF_RATIO_THRESHOLD` docs.
88/// Find the cliff cutoff index in a descending-sorted score list.
89/// Returns the count of entries that survive (the index right
90/// after the last pre-cliff score), or `scores.len()` when there's
91/// no cliff in the list.
92///
93/// AcmeFlow 1.0.86 regression (2026-06-04): the candidate-level
94/// cliff in `file_score_floor` missed cliffs that appear only after
95/// file aggregation. Symbol candidates had a smooth gradient
96/// (42/31/29/27/25/19/18) — no consecutive pair triggered 0.70.
97/// But file-level top scores were 42/31/19/18 — a clear 0.61 cliff
98/// at rank 3. This helper operates on the aggregated file scores
99/// so cliff detection happens at the right granularity.
100///
101/// Use:
102///   let cut = cliff_cutoff_index(file_scores.iter().map(|f| f.0));
103///   file_scores.truncate(cut);
104pub 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; // keep through index i, cut starts at i+1
117        }
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    // Cliff pass: walk pairs of consecutive scores. The first pair
129    // where the LOWER score is < 0.70 of the higher score marks the
130    // cohort boundary. Floor becomes the HIGHER score (inclusive
131    // cutoff — pre-cliff cohort survives).
132    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
147/// Append a file-score entry if `file` is not yet seen AND `score` is at or
148/// above `floor`. Returns true when an entry was pushed. The caller owns the
149/// `seen_files` set + the destination Vec — keeps this helper allocation-free
150/// and reusable across CLI / MCP orchestration loops that interleave file
151/// scoring with other ledger walks.
152pub 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
187// ---------------------------------------------------------------------------
188// Plan M t-004 (1.0.98): full scoring walk lifted from CLI prepare_change
189// + MCP prepare_change handler. Closes the Plan F TODO. Both call sites
190// now share one canonical pipeline:
191//   aggregate_candidate_data → propagate_caller_invariants → finalize_file_scores
192// ---------------------------------------------------------------------------
193
194/// Per-row file score tuple shape used by the prepare-change pipeline.
195/// (score, file, layer, last_touched_days, hot, top_symbol, why)
196///
197/// Kept as a tuple (not a struct) because the t-009/t-003 sites already
198/// destructure it positionally in many places. The longer-term refactor
199/// can promote this to `FileScoreEntry` (which exists above) once all
200/// call sites use field access.
201pub type FileScoreTuple = (f64, String, String, Option<f64>, bool, String, String);
202
203/// Holds the six parallel collections built up by the per-candidate
204/// scan plus the top symbol id (driver for downstream BFS) and the
205/// `seen_inv` dedup set (threaded into the subsequent caller-invariant
206/// propagation pass so both invariant sources share one namespace).
207#[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
219/// Main candidate-scan pass. Iterates the ranked candidate list once,
220/// populating all six parallel accumulators:
221///   - `by_layer` — entry-point JSON grouped by classified layer
222///   - `design_invariants` — Invariant ledger entries, deduped on summary
223///   - `known_hazards` — Hazard ledger entries
224///   - `validation_scenarios_ledger` — ValidationScenario entries, deduped
225///   - `effects_summary` — declared effects (low-signal suppressed unless sole)
226///   - `file_scores` — per-file score row used by likely_edit_files
227///
228/// Plan M t-004 (1.0.98): lifted from CLI commands/prepare_change.rs and
229/// the MCP prepare_change handler. Both surfaces now call this directly,
230/// closing the Plan F TODO that flagged the duplication.
231pub 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    // Only include effects from symbols scoring ≥25% of the top score to reduce noise.
252    let effect_score_floor = candidates.first().map(|(s, _)| s * 0.25).unwrap_or(0.0);
253
254    // AcmeFlow refinement #2 (1.0.83): file_score_floor was bumped
255    // 0.25 → 0.40 to cut targeted-query noise.
256    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        // File tracking — capture the contributing symbol + its top match
284        // reason so prepare-change can answer "why this file?".
285        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                        // 1.0.86: include entry_id so downstream "duplicate"
307                        // sections (preserve, suggested_test_coverage,
308                        // scenario_tests) can reference by id.
309                        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        // Effects — only from sufficiently-scoring candidates to reduce noise.
335        // Low-signal effects (throw, random, log, pure, time.read, time.sleep)
336        // are suppressed unless they are the only effects declared on the symbol.
337        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        // Add to by_layer with full enrichment (confidence/bucket/match_reasons).
357        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
393/// Plan J t-001 caller-invariant propagation.
394///
395/// Walks each candidate's direct callers (depth=1) and returns any
396/// Invariant ledger entries that aren't already in `seen_inv`. Each
397/// returned value is tagged `from_caller: true` so the agent can tell
398/// which upstream contract is at stake.
399///
400/// `seen_inv` is mutated — new invariant summaries are inserted as the
401/// helper accumulates them, preserving the original loop's dedup
402/// semantics with the main-candidate invariants gathered earlier.
403///
404/// Plan M t-004 (1.0.98): lifted to core so CLI + MCP share one impl.
405pub 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
491/// Detect conflict risk for a single file. Returns Some(reason) if the
492/// file has unresolved merge markers or git status modifications;
493/// None when the file is clean.
494///
495/// Plan M t-004 (1.0.98): lifted from CLI prepare_change.rs so the MCP
496/// handler's `likely_edit_files` can carry conflict_detail too (was a
497/// CLI-only field; now both surfaces emit it).
498pub 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
522/// Sort + cliff-cut + build the `likely_edit_files` JSON array.
523///
524/// Mutates `file_scores` in place: sorts hot-first then score-desc, then
525/// applies the file-level cliff cut. Returns the JSON value array used
526/// directly in the prepare_change output.
527///
528/// `dirty_files` is taken by ref so the caller's hoisted git_dirty_files()
529/// result is reusable for downstream stale_symbols.
530///
531/// Plan M t-004 (1.0.98): lifted to core. Whether the helper computes
532/// `conflict_detail` is controlled by `include_conflict_detail` so MCP
533/// can gain the richer output without forcing it on legacy consumers
534/// (set true on both call sites today; flag exists for forward control).
535pub 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
577/// Convenience: hoist git_dirty_files() for callers that want to share
578/// the same set between finalize_file_scores and a downstream
579/// stale_symbols scan. Just re-exports the underlying helper for API
580/// discoverability.
581pub 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        // 40 → 5 is a 0.125 ratio — obvious cliff. Floor locks to
611        // rank 1 (40); only rank 1 survives. (Reading 1.0.85 design:
612        // cliff IS the dominant signal even with just 2 candidates.)
613        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        // Pin the cliff threshold so future tunings are visible.
631        assert!((CLIFF_RATIO_THRESHOLD - 0.70).abs() < 1e-9);
632    }
633
634    #[test]
635    fn file_score_floor_cliff_cuts_at_cohort_boundary() {
636        // AcmeFlow refinement #2 (1.0.85, deep half): the
637        // literal field-eval case. Query "Drift Pad scheduler sync"
638        // produced 42 / 31 / 19 / 18. Ratios:
639        //   31/42 = 0.74 (above threshold, no cut)
640        //   19/31 = 0.61 (below 0.70 → CLIFF, cut at 31)
641        // Floor = 31. Files at 42 and 31 survive; 19 and 18 cut.
642        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        // Top=80 dominates rank-2=20 (ratio 0.25 < 0.70). Cliff at
658        // rank 2 → floor = 80. Only rank 1 survives.
659        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        // Truly smooth decay: all consecutive ratios > 0.70. No
671        // cliff fires. Floor = 100 * 0.40 = 40. All ranks (100/
672        // 90/80/75) above floor → all survive.
673        //
674        // Note: must end the candidate list before any pair drops
675        // below 0.70 — otherwise the cliff pass fires. Real broad
676        // queries with this smooth a decay are uncommon but possible
677        // (4 near-equal options).
678        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        // 100 / 90 / 80 / 70 / 30 — smooth decay then cliff at
691        // 70→30. Floor locks to 70 (last pre-cliff score), so
692        // 100/90/80/70 survive, 30 cut. Cliff dominates the
693        // relative floor (which would have been 40 and admitted 70
694        // anyway, but also 60/50 if they existed).
695        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        // 1.0.87 regression case: file-level scores 42/31/19/18.
715        // 31/42 = 0.74 (no cut), 19/31 = 0.61 (cut at index 2).
716        // Keep first 2 entries; cut starting at index 2.
717        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        // All consecutive ratios > 0.70 → no cliff → keep all.
724        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        // Cliff between rank 1 and rank 2 — relative floor would
737        // still admit rank 2 (since rank 2 = 60% * top > 40% floor),
738        // but cliff rule overrides.
739        let cands = vec![
740            (100.0_f64, "winner".into()),
741            (60.0, "after_cliff".into()), // 0.60 ratio < 0.70 threshold
742        ];
743        let floor = file_score_floor(&cands);
744        // Cliff at pair → floor = rank 1 = 100. Only rank 1 survives.
745        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        // The reason should be non-empty whether explain_match returns a
849        // ledger-derived line or our "contains symbol" fallback.
850        assert!(!out[0].why.is_empty());
851    }
852}