Skip to main content

edda_ask/
lib.rs

1use edda_core::Event;
2use edda_ledger::DecisionView;
3use edda_ledger::Ledger;
4use serde::Serialize;
5
6pub mod staleness;
7
8const SEMANTIC_CANDIDATE_LIMIT: usize = 500;
9
10#[derive(Debug)]
11struct ScoredDecision {
12    row: DecisionView,
13    score: f64,
14}
15
16// ── Input type detection ─────────────────────────────────────────────
17
18/// Detected input type for a query string.
19#[derive(Debug, PartialEq)]
20pub enum InputType {
21    /// "db.engine" — contains '.' matching word.word
22    ExactKey(String),
23    /// "db" — matches a known domain
24    Domain(String),
25    /// "postgres" — default keyword search
26    Keyword(String),
27    /// Empty query — show all active decisions
28    Overview,
29}
30
31/// Classify a query string into one of the four input types.
32pub fn detect_input_type(query: &str, known_domains: &[String]) -> InputType {
33    let q = query.trim();
34    if q.is_empty() {
35        return InputType::Overview;
36    }
37    // Check for exact key pattern: word.word (e.g. "db.engine")
38    if q.contains('.') && q.split('.').count() >= 2 && q.split('.').all(|p| !p.is_empty()) {
39        return InputType::ExactKey(q.to_string());
40    }
41    // Check if query matches a known domain (case-insensitive)
42    let q_lower = q.to_lowercase();
43    if known_domains.iter().any(|d| d.to_lowercase() == q_lower) {
44        return InputType::Domain(q.to_string());
45    }
46    InputType::Keyword(q.to_string())
47}
48
49// ── Result types ─────────────────────────────────────────────────────
50
51#[derive(Debug, Serialize)]
52pub struct AskResult {
53    pub query: String,
54    pub input_type: String,
55    pub decisions: Vec<DecisionHit>,
56    pub timeline: Vec<DecisionHit>,
57    pub related_commits: Vec<CommitHit>,
58    pub related_notes: Vec<NoteHit>,
59    pub conversations: Vec<ConversationHit>,
60    /// Tasks whose title/receipt/reason match the query — the rail's answer to
61    /// "what shipped about X, verified how" (GH-404).
62    #[serde(skip_serializing_if = "Vec::is_empty")]
63    pub tasks: Vec<TaskHit>,
64    #[serde(skip_serializing_if = "Vec::is_empty")]
65    pub dependents: Vec<DependentHit>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub override_risk: Option<OverrideRisk>,
68}
69
70/// A task matched by `ask`. The receipt is the point — it is where a finished
71/// task records what it did and how it was verified.
72#[derive(Debug, Clone, Serialize)]
73pub struct TaskHit {
74    pub task_id: u64,
75    pub title: String,
76    pub status: String,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub receipt: Option<String>,
79    #[serde(skip_serializing_if = "Vec::is_empty")]
80    pub evidence_paths: Vec<String>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct DecisionHit {
85    pub event_id: String,
86    pub key: String,
87    pub value: String,
88    pub reason: String,
89    pub domain: String,
90    pub branch: String,
91    pub ts: String,
92    pub is_active: bool,
93    /// Tags parsed from JSON array in the decision row.
94    #[serde(skip_serializing_if = "Vec::is_empty")]
95    pub tags: Vec<String>,
96    /// Village scope identifier.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub village_id: Option<String>,
99    /// Decision-code freshness (q334 EDDA-STALENESS1). None ⇒ not checked
100    /// (feature off, or repo_root missing at query time). Serialized only when
101    /// present so existing JSON consumers stay unaffected.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub staleness: Option<crate::staleness::DecisionStaleness>,
104}
105
106#[derive(Debug, Clone, Serialize)]
107pub struct CommitHit {
108    pub event_id: String,
109    pub title: String,
110    pub purpose: String,
111    pub ts: String,
112    pub branch: String,
113    pub match_type: String,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct NoteHit {
118    pub event_id: String,
119    pub text: String,
120    pub ts: String,
121    pub branch: String,
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct ConversationHit {
126    pub doc_id: String,
127    pub session_id: String,
128    pub ts: String,
129    pub snippet: String,
130    pub rank: f64,
131}
132
133// ── Impact types ────────────────────────────────────────────────────
134
135#[derive(Debug, Clone, Serialize)]
136pub struct DependentHit {
137    pub key: String,
138    pub value: String,
139    pub dep_type: String,
140    /// Hop distance from the queried key (1 = direct dependent).
141    pub depth: usize,
142}
143
144#[derive(Debug, Clone, Serialize)]
145pub struct OverrideRisk {
146    pub level: String,
147    pub dependent_count: usize,
148    pub suggestion: Option<String>,
149}
150
151// ── Options ──────────────────────────────────────────────────────────
152
153pub struct AskOptions {
154    pub limit: usize,
155    pub include_superseded: bool,
156    pub branch: Option<String>,
157    pub impact: bool,
158    /// ISO 8601 lower bound (inclusive) for temporal filtering.
159    pub after: Option<String>,
160    /// ISO 8601 upper bound (inclusive) for temporal filtering.
161    pub before: Option<String>,
162    /// Filter decisions whose tags contain any of these values (OR semantics).
163    pub tags: Vec<String>,
164    /// Filter decisions belonging to a specific village.
165    pub village_id: Option<String>,
166}
167
168impl Default for AskOptions {
169    fn default() -> Self {
170        Self {
171            limit: 20,
172            include_superseded: false,
173            branch: None,
174            impact: false,
175            after: None,
176            before: None,
177            tags: vec![],
178            village_id: None,
179        }
180    }
181}
182
183/// Transcript search callback type.
184pub type TranscriptSearchFn = dyn Fn(&str, usize) -> Vec<ConversationHit>;
185
186// ── Core ask function ────────────────────────────────────────────────
187
188pub fn ask(
189    ledger: &Ledger,
190    query: &str,
191    opts: &AskOptions,
192    transcript_search: Option<&TranscriptSearchFn>,
193) -> anyhow::Result<AskResult> {
194    let domains = ledger.list_domains()?;
195    let input_type = detect_input_type(query, &domains);
196
197    // Branch filter helper: keep only decisions matching the requested branch
198    let branch_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
199        match &opts.branch {
200            Some(b) => hits.into_iter().filter(|d| d.branch == *b).collect(),
201            None => hits,
202        }
203    };
204
205    // Tags filter helper: keep only decisions that have at least one matching tag (OR semantics)
206    let tags_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
207        if opts.tags.is_empty() {
208            return hits;
209        }
210        hits.into_iter()
211            .filter(|d| d.tags.iter().any(|t| opts.tags.contains(t)))
212            .collect()
213    };
214
215    // Village filter helper: keep only decisions belonging to the specified village
216    let village_filter = |hits: Vec<DecisionHit>| -> Vec<DecisionHit> {
217        match &opts.village_id {
218            Some(v) => hits
219                .into_iter()
220                .filter(|d| d.village_id.as_deref() == Some(v.as_str()))
221                .collect(),
222            None => hits,
223        }
224    };
225
226    let after_ref = opts.after.as_deref();
227    let before_ref = opts.before.as_deref();
228
229    let (decisions, timeline) = match &input_type {
230        InputType::ExactKey(key) => {
231            let all = ledger
232                .decision_timeline(key, after_ref, before_ref)?
233                .into_iter()
234                .map(|r| to_decision_hit(&r))
235                .collect::<Vec<_>>();
236            let all = branch_filter(all);
237            let active: Vec<DecisionHit> = if opts.include_superseded {
238                all.clone()
239            } else {
240                all.iter().filter(|d| d.is_active).cloned().collect()
241            };
242            (active, all)
243        }
244        InputType::Domain(domain) => {
245            let active = branch_filter(
246                ledger
247                    .active_decisions_limited(
248                        Some(domain),
249                        None,
250                        after_ref,
251                        before_ref,
252                        opts.limit,
253                    )?
254                    .into_iter()
255                    .map(|r| to_decision_hit(&r))
256                    .collect(),
257            );
258            let tl = branch_filter(
259                ledger
260                    .domain_timeline(domain, after_ref, before_ref)?
261                    .into_iter()
262                    .map(|r| to_decision_hit(&r))
263                    .collect(),
264            );
265            (active, tl)
266        }
267        InputType::Keyword(kw) => {
268            let mut semantic_hits = semantic_decision_search(
269                ledger,
270                kw,
271                opts.branch.as_deref(),
272                after_ref,
273                before_ref,
274                opts.limit,
275            )?;
276
277            let lexical_fallback = ledger
278                .active_decisions_limited(None, Some(kw), after_ref, before_ref, opts.limit)?
279                .into_iter()
280                .map(|r| to_decision_hit(&r));
281
282            for hit in lexical_fallback {
283                if !semantic_hits.iter().any(|h| h.event_id == hit.event_id) {
284                    semantic_hits.push(hit);
285                }
286            }
287
288            let mut hits = branch_filter(semantic_hits);
289            if hits.len() > opts.limit {
290                hits.truncate(opts.limit);
291            }
292
293            if opts.include_superseded {
294                // Scan only note events for superseded decisions matching keyword
295                let events = ledger.iter_events_by_type("note")?;
296                let kw_lower = kw.to_lowercase();
297                for event in &events {
298                    if let Some(ref b) = opts.branch {
299                        if event.branch != *b {
300                            continue;
301                        }
302                    }
303                    if event.event_type == "note"
304                        && edda_core::decision::is_decision(&event.payload)
305                    {
306                        if let Some(dp) = edda_core::decision::extract_decision(&event.payload) {
307                            let reason_str = dp.reason.as_deref().unwrap_or("").to_string();
308                            if (dp.key.to_lowercase().contains(&kw_lower)
309                                || dp.value.to_lowercase().contains(&kw_lower)
310                                || reason_str.to_lowercase().contains(&kw_lower))
311                                && !hits.iter().any(|h| h.event_id == event.event_id)
312                            {
313                                let domain = edda_core::decision::extract_domain(&dp.key);
314                                hits.push(DecisionHit {
315                                    event_id: event.event_id.clone(),
316                                    key: dp.key,
317                                    value: dp.value,
318                                    reason: reason_str,
319                                    domain,
320                                    branch: event.branch.clone(),
321                                    ts: event.ts.clone(),
322                                    is_active: false,
323                                    tags: dp.tags.unwrap_or_default(),
324                                    village_id: dp.village_id,
325                                    staleness: None,
326                                });
327                            }
328                        }
329                    }
330                }
331            }
332            (hits, vec![])
333        }
334        InputType::Overview => {
335            let active = branch_filter(
336                ledger
337                    .active_decisions_limited(None, None, after_ref, before_ref, opts.limit)?
338                    .into_iter()
339                    .map(|r| to_decision_hit(&r))
340                    .collect(),
341            );
342            (active, vec![])
343        }
344    };
345
346    // Apply tags filter (OR semantics) across all code paths
347    let decisions = tags_filter(decisions);
348    let timeline = tags_filter(timeline);
349
350    // Apply village filter across all code paths
351    let decisions = village_filter(decisions);
352    let timeline = village_filter(timeline);
353
354    // Collect decision event_ids for evidence chain matching
355    let decision_event_ids: Vec<&str> = decisions
356        .iter()
357        .map(|d| d.event_id.as_str())
358        .chain(timeline.iter().map(|d| d.event_id.as_str()))
359        .collect();
360
361    let q = query.trim();
362    // SQL push-down: find related commits and notes via targeted queries
363    let commit_events =
364        ledger.find_related_commits(opts.branch.as_deref(), q, &decision_event_ids, opts.limit)?;
365    let related_commits = to_commit_hits(&commit_events, &decision_event_ids, q, opts.limit);
366    let note_events = ledger.find_related_notes(opts.branch.as_deref(), q, opts.limit)?;
367    let mut related_notes = find_checkpoint_notes(ledger, q, opts)?;
368    related_notes.extend(to_note_hits(&note_events, opts.limit));
369    related_notes.truncate(opts.limit);
370
371    let conversations = match transcript_search {
372        Some(search_fn) if !q.is_empty() => search_fn(q, opts.limit),
373        _ => vec![],
374    };
375
376    // Tasks whose text matches the query. What a reader is after is "what
377    // shipped about X, verified how", so a task matches when the query appears
378    // in its title, receipt, or failure reason — the receipt being the point.
379    // Newest first, since a rail answer is usually a recent one (GH-404).
380    //
381    // Deliberately not branch-filtered, unlike decisions above: the task rail is
382    // a workspace-global queue, not a per-branch record, so `--branch` narrowing
383    // the decisions but not the tasks is the intended asymmetry.
384    let tasks: Vec<TaskHit> = if q.is_empty() {
385        vec![]
386    } else {
387        let needle = q.to_lowercase();
388        let mut views = ledger.task_views()?;
389        views.sort_by_key(|t| std::cmp::Reverse(t.task_id)); // newest first
390        views
391            .into_iter()
392            .filter(|t| {
393                let hay = format!(
394                    "{} {} {}",
395                    t.title,
396                    t.receipt.as_deref().unwrap_or(""),
397                    t.failure_reason.as_deref().unwrap_or("")
398                )
399                .to_lowercase();
400                hay.contains(&needle)
401            })
402            .take(opts.limit)
403            .map(|t| TaskHit {
404                task_id: t.task_id,
405                title: t.title,
406                status: t.status.to_string(),
407                receipt: t.receipt,
408                evidence_paths: t.evidence_paths,
409            })
410            .collect()
411    };
412
413    let input_type_str = match &input_type {
414        InputType::ExactKey(_) => "exact_key",
415        InputType::Domain(_) => "domain",
416        InputType::Keyword(_) => "keyword",
417        InputType::Overview => "overview",
418    };
419
420    // Impact analysis: ExactKey or Domain queries with --impact
421    let (dependents, override_risk) = if opts.impact {
422        match &input_type {
423            InputType::ExactKey(key) => compute_impact(ledger, key)?,
424            InputType::Domain(domain) => compute_domain_impact(ledger, domain)?,
425            _ => (vec![], None),
426        }
427    } else {
428        (vec![], None)
429    };
430
431    Ok(AskResult {
432        query: q.to_string(),
433        input_type: input_type_str.to_string(),
434        decisions,
435        timeline,
436        related_commits,
437        related_notes,
438        conversations,
439        tasks,
440        dependents,
441        override_risk,
442    })
443}
444
445// ── Impact analysis helpers ──────────────────────────────────────────
446
447fn compute_impact(
448    ledger: &Ledger,
449    key: &str,
450) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
451    let transitive_deps = ledger.transitive_dependents_of(key, 3)?;
452    let dependents: Vec<DependentHit> = transitive_deps
453        .iter()
454        .map(|(dep, decision, depth)| DependentHit {
455            key: decision.key.clone(),
456            value: decision.value.clone(),
457            dep_type: dep.dep_type.clone(),
458            depth: *depth,
459        })
460        .collect();
461    let risk = compute_override_risk(&dependents);
462    Ok((dependents, Some(risk)))
463}
464
465fn compute_override_risk(dependents: &[DependentHit]) -> OverrideRisk {
466    let count = dependents.len();
467
468    // Weighted scoring:
469    //   explicit direct deps  x3
470    //   auto_domain direct    x1
471    //   transitive deps       x2  (depth > 1)
472    //   cross-domain spread   x2
473    let explicit_direct: usize = dependents
474        .iter()
475        .filter(|d| d.dep_type == "explicit" && d.depth == 1)
476        .count();
477    let auto_domain_direct: usize = dependents
478        .iter()
479        .filter(|d| d.dep_type == "auto_domain" && d.depth == 1)
480        .count();
481    let transitive: usize = dependents.iter().filter(|d| d.depth > 1).count();
482
483    // Count distinct domains among dependents
484    let mut domains: std::collections::HashSet<&str> = std::collections::HashSet::new();
485    for d in dependents {
486        if let Some(dom) = d.key.split('.').next() {
487            domains.insert(dom);
488        }
489    }
490    let cross_domain_bonus = if domains.len() > 1 { domains.len() } else { 0 };
491
492    let score = explicit_direct * 3 + auto_domain_direct + transitive * 2 + cross_domain_bonus * 2;
493
494    let level = if score <= 2 {
495        "LOW"
496    } else if score <= 6 {
497        "MEDIUM"
498    } else {
499        "HIGH"
500    };
501
502    // Suggestion: reverse BFS order = override leaves first
503    let suggestion = if count > 0 {
504        let mut sorted: Vec<&DependentHit> = dependents.iter().collect();
505        sorted.sort_by_key(|dependent| std::cmp::Reverse(dependent.depth));
506        let keys: Vec<&str> = sorted.iter().map(|d| d.key.as_str()).collect();
507        Some(format!("建議覆蓋順序: {}", keys.join(" → ")))
508    } else {
509        None
510    };
511
512    OverrideRisk {
513        level: level.to_string(),
514        dependent_count: count,
515        suggestion,
516    }
517}
518
519/// Aggregate impact across all active keys in a domain.
520fn compute_domain_impact(
521    ledger: &Ledger,
522    domain: &str,
523) -> anyhow::Result<(Vec<DependentHit>, Option<OverrideRisk>)> {
524    let keys_in_domain = ledger.active_decisions(Some(domain), None, None, None)?;
525    if keys_in_domain.is_empty() {
526        return Ok((vec![], None));
527    }
528
529    let mut all_dependents: Vec<DependentHit> = Vec::new();
530    let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
531
532    for decision in &keys_in_domain {
533        let (deps, _) = compute_impact(ledger, &decision.key)?;
534        for d in deps {
535            if seen_keys.insert(d.key.clone()) {
536                all_dependents.push(d);
537            }
538        }
539    }
540
541    let risk = if all_dependents.is_empty() {
542        Some(OverrideRisk {
543            level: "LOW".to_string(),
544            dependent_count: 0,
545            suggestion: None,
546        })
547    } else {
548        Some(compute_override_risk(&all_dependents))
549    };
550
551    Ok((all_dependents, risk))
552}
553
554// ── Related event helpers ────────────────────────────────────────────
555
556/// Convert SQL-prefiltered commit events into `CommitHit` with match_type classification.
557/// Events are already filtered by SQL (branch, keyword/evidence); this determines the
558/// specific match_type ("evidence" or "title") for each.
559fn to_commit_hits(
560    events: &[Event],
561    decision_event_ids: &[&str],
562    query: &str,
563    limit: usize,
564) -> Vec<CommitHit> {
565    let mut hits: Vec<CommitHit> = Vec::new();
566    let q_lower = query.to_lowercase();
567
568    for event in events {
569        let title = event
570            .payload
571            .get("title")
572            .and_then(|v| v.as_str())
573            .unwrap_or("");
574        let purpose = event
575            .payload
576            .get("purpose")
577            .and_then(|v| v.as_str())
578            .unwrap_or("");
579
580        // Determine match type
581        let mut match_type = None;
582        for ref_id in &event.refs.events {
583            if decision_event_ids.contains(&ref_id.as_str()) {
584                match_type = Some("evidence");
585                break;
586            }
587        }
588        if match_type.is_none() {
589            for prov in &event.refs.provenance {
590                if decision_event_ids.contains(&prov.target.as_str()) {
591                    match_type = Some("evidence");
592                    break;
593                }
594            }
595        }
596        if match_type.is_none()
597            && !query.is_empty()
598            && (title.to_lowercase().contains(&q_lower)
599                || purpose.to_lowercase().contains(&q_lower))
600        {
601            match_type = Some("title");
602        }
603
604        if let Some(mt) = match_type {
605            if !hits.iter().any(|h| h.event_id == event.event_id) {
606                hits.push(CommitHit {
607                    event_id: event.event_id.clone(),
608                    title: title.to_string(),
609                    purpose: purpose.to_string(),
610                    ts: event.ts.clone(),
611                    branch: event.branch.clone(),
612                    match_type: mt.to_string(),
613                });
614                if hits.len() >= limit {
615                    break;
616                }
617            }
618        }
619    }
620
621    hits
622}
623
624/// Convert SQL-prefiltered note events into `NoteHit`.
625/// Events are already filtered by SQL (branch, keyword, excluding decisions/digests).
626fn to_note_hits(events: &[Event], limit: usize) -> Vec<NoteHit> {
627    events
628        .iter()
629        .take(limit)
630        .map(|event| {
631            let text = event
632                .payload
633                .get("text")
634                .and_then(|v| v.as_str())
635                .unwrap_or("");
636            NoteHit {
637                event_id: event.event_id.clone(),
638                text: text.to_string(),
639                ts: event.ts.clone(),
640                branch: event.branch.clone(),
641            }
642        })
643        .collect()
644}
645
646fn semantic_decision_search(
647    ledger: &Ledger,
648    query: &str,
649    branch: Option<&str>,
650    after: Option<&str>,
651    before: Option<&str>,
652    limit: usize,
653) -> anyhow::Result<Vec<DecisionHit>> {
654    if limit == 0 {
655        return Ok(vec![]);
656    }
657
658    let candidates =
659        ledger.active_decisions_limited(None, None, after, before, SEMANTIC_CANDIDATE_LIMIT)?;
660
661    let scored = rank_decisions_by_similarity(query, candidates, branch);
662    Ok(scored
663        .into_iter()
664        .take(limit)
665        .map(|s| to_decision_hit(&s.row))
666        .collect())
667}
668
669fn rank_decisions_by_similarity(
670    query: &str,
671    candidates: Vec<DecisionView>,
672    branch: Option<&str>,
673) -> Vec<ScoredDecision> {
674    let query_tokens = tokenize_for_similarity(query);
675    if query_tokens.is_empty() {
676        return vec![];
677    }
678
679    let mut tokenized_docs: Vec<(DecisionView, Vec<String>)> = Vec::new();
680    for row in candidates {
681        if branch.is_some_and(|b| row.branch != b) {
682            continue;
683        }
684        let text = format!("{} {} {} {}", row.domain, row.key, row.value, row.reason);
685        let tokens = tokenize_for_similarity(&text);
686        if !tokens.is_empty() {
687            tokenized_docs.push((row, tokens));
688        }
689    }
690
691    if tokenized_docs.is_empty() {
692        return vec![];
693    }
694
695    let mut doc_freq: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
696    for (_, tokens) in &tokenized_docs {
697        let unique: std::collections::HashSet<&str> = tokens.iter().map(|t| t.as_str()).collect();
698        for token in unique {
699            *doc_freq.entry(token.to_string()).or_insert(0) += 1;
700        }
701    }
702
703    let n_docs = tokenized_docs.len() as f64;
704    let query_tf = term_freq(&query_tokens);
705    let query_norm = vector_norm(&query_tf, &doc_freq, n_docs);
706
707    let mut scored: Vec<ScoredDecision> = Vec::new();
708    for (row, tokens) in tokenized_docs {
709        let tf = term_freq(&tokens);
710        let doc_norm = vector_norm(&tf, &doc_freq, n_docs);
711        if doc_norm <= f64::EPSILON || query_norm <= f64::EPSILON {
712            continue;
713        }
714
715        let mut dot = 0.0;
716        for (token, q_tf) in &query_tf {
717            if let Some(d_tf) = tf.get(token) {
718                let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
719                dot += (*q_tf as f64 * idf) * (*d_tf as f64 * idf);
720            }
721        }
722
723        let score = dot / (query_norm * doc_norm);
724        if score > 0.0 {
725            scored.push(ScoredDecision { row, score });
726        }
727    }
728
729    scored.sort_by(|a, b| {
730        b.score
731            .partial_cmp(&a.score)
732            .unwrap_or(std::cmp::Ordering::Equal)
733    });
734    scored
735}
736
737fn tokenize_for_similarity(text: &str) -> Vec<String> {
738    text.split(|c: char| !c.is_alphanumeric())
739        .filter_map(|t| {
740            let normalized = t.trim().to_lowercase();
741            if normalized.len() >= 2 {
742                Some(normalized)
743            } else {
744                None
745            }
746        })
747        .collect()
748}
749
750fn term_freq(tokens: &[String]) -> std::collections::HashMap<String, usize> {
751    let mut tf = std::collections::HashMap::new();
752    for token in tokens {
753        *tf.entry(token.clone()).or_insert(0) += 1;
754    }
755    tf
756}
757
758fn inv_doc_freq(df: usize, n_docs: f64) -> f64 {
759    ((n_docs + 1.0) / (df as f64 + 1.0)).ln() + 1.0
760}
761
762fn vector_norm(
763    tf: &std::collections::HashMap<String, usize>,
764    doc_freq: &std::collections::HashMap<String, usize>,
765    n_docs: f64,
766) -> f64 {
767    let mut sum_sq = 0.0;
768    for (token, count) in tf {
769        let idf = inv_doc_freq(doc_freq.get(token).copied().unwrap_or(0), n_docs);
770        let weight = *count as f64 * idf;
771        sum_sq += weight * weight;
772    }
773    sum_sq.sqrt()
774}
775
776// ── Human-readable formatting ────────────────────────────────────────
777
778pub fn format_human(result: &AskResult) -> String {
779    let mut out = String::new();
780
781    if !result.decisions.is_empty() {
782        out.push_str("── Decisions ──────────────────────────\n");
783        for d in &result.decisions {
784            let status = if d.is_active { "active" } else { "superseded" };
785            out.push_str(&format!(
786                "  {} = {} — {}\n  branch: {} | {} | {}\n",
787                d.key, d.value, d.reason, d.branch, d.ts, status
788            ));
789            if let Some(st) = &d.staleness {
790                if st.is_stale {
791                    let bad: Vec<String> = st
792                        .paths
793                        .iter()
794                        .filter(|p| {
795                            p.status != staleness::PathStatus::Fresh
796                                && p.status != staleness::PathStatus::Unknown
797                        })
798                        .map(|p| format!("{} ({:?})", p.path, p.status))
799                        .collect();
800                    if !bad.is_empty() {
801                        out.push_str(&format!("  ⚠ stale-code hint: {}\n", bad.join(", ")));
802                    }
803                }
804            }
805            out.push('\n');
806        }
807    }
808
809    if !result.timeline.is_empty() {
810        out.push_str("── Timeline ───────────────────────────\n");
811        for d in &result.timeline {
812            let status = if d.is_active { "active" } else { "superseded" };
813            out.push_str(&format!(
814                "  {}  {} = {}  ({})\n",
815                d.ts, d.key, d.value, status
816            ));
817        }
818        out.push('\n');
819    }
820
821    if !result.related_commits.is_empty() {
822        out.push_str("── Related Commits ────────────────────\n");
823        for c in &result.related_commits {
824            out.push_str(&format!(
825                "  {} ({}, {})\n  match: {}\n\n",
826                c.title, c.ts, c.branch, c.match_type
827            ));
828        }
829    }
830
831    if !result.related_notes.is_empty() {
832        out.push_str("── Related Notes ──────────────────────\n");
833        for n in &result.related_notes {
834            if n.text.len() > 120 {
835                let end = n.text.floor_char_boundary(117);
836                out.push_str(&format!(
837                    "  \"{}...\" ({}, {})\n\n",
838                    &n.text[..end],
839                    n.ts,
840                    n.branch
841                ));
842            } else {
843                out.push_str(&format!("  \"{}\" ({}, {})\n\n", n.text, n.ts, n.branch));
844            }
845        }
846    }
847
848    if !result.tasks.is_empty() {
849        out.push_str("── Tasks ──────────────────────────────\n");
850        for t in &result.tasks {
851            out.push_str(&format!("  #{} [{}] {}\n", t.task_id, t.status, t.title));
852            if let Some(r) = &t.receipt {
853                let excerpt = if r.len() > 120 {
854                    format!("{}...", &r[..r.floor_char_boundary(117)])
855                } else {
856                    r.clone()
857                };
858                out.push_str(&format!("     receipt: {excerpt}\n"));
859            }
860            if !t.evidence_paths.is_empty() {
861                out.push_str(&format!("     evidence: {}\n", t.evidence_paths.join(", ")));
862            }
863            out.push('\n');
864        }
865    }
866
867    if !result.dependents.is_empty() {
868        out.push_str("── Dependents ─────────────────────────\n");
869        for d in &result.dependents {
870            out.push_str(&format!(
871                "  \u{2192} {} = {} ({}, depth {})\n",
872                d.key, d.value, d.dep_type, d.depth
873            ));
874        }
875        out.push('\n');
876    }
877
878    if let Some(ref risk) = result.override_risk {
879        out.push_str("── Override Risk ──────────────────────\n");
880        let emoji = match risk.level.as_str() {
881            "HIGH" => "\u{1f534}",
882            "MEDIUM" => "\u{1f7e1}",
883            _ => "\u{1f7e2}",
884        };
885        out.push_str(&format!(
886            "  {} {} ({} dependents)\n",
887            emoji, risk.level, risk.dependent_count
888        ));
889        if let Some(ref suggestion) = risk.suggestion {
890            out.push_str(&format!("  {suggestion}\n"));
891        }
892        out.push('\n');
893    }
894
895    if !result.conversations.is_empty() {
896        out.push_str("── Conversations ──────────────────────\n");
897        for c in &result.conversations {
898            out.push_str(&format!(
899                "  [{}] {}\n  rank: {:.2}\n\n",
900                c.session_id, c.snippet, c.rank
901            ));
902        }
903    }
904
905    if out.is_empty() {
906        out.push_str("No results found.\n");
907    }
908
909    out
910}
911
912// ── Internal helpers ─────────────────────────────────────────────────
913
914fn to_decision_hit(row: &DecisionView) -> DecisionHit {
915    DecisionHit {
916        event_id: row.event_id.clone(),
917        key: row.key.clone(),
918        value: row.value.clone(),
919        reason: row.reason.clone(),
920        domain: row.domain.clone(),
921        branch: row.branch.clone(),
922        ts: row.ts.clone().unwrap_or_default(),
923        is_active: matches!(row.status.as_str(), "active" | "experimental"),
924        tags: row.tags.clone(),
925        village_id: row.village_id.clone(),
926        staleness: None,
927    }
928}
929
930fn find_checkpoint_notes(
931    ledger: &Ledger,
932    query: &str,
933    opts: &AskOptions,
934) -> anyhow::Result<Vec<NoteHit>> {
935    let query = query.trim().to_lowercase();
936    let terms: Vec<&str> = query
937        .split(" or ")
938        .map(str::trim)
939        .filter(|term| !term.is_empty())
940        .collect();
941
942    let mut events = ledger.iter_events_by_type("checkpoint")?;
943    events.reverse();
944    Ok(events
945        .into_iter()
946        .filter(|event| {
947            opts.branch
948                .as_deref()
949                .is_none_or(|branch| branch == event.branch)
950        })
951        .filter(|event| {
952            opts.after
953                .as_deref()
954                .is_none_or(|after| event.ts.as_str() >= after)
955                && opts
956                    .before
957                    .as_deref()
958                    .is_none_or(|before| event.ts.as_str() <= before)
959        })
960        .filter_map(|event| {
961            let payload: edda_core::event::CheckpointPayload =
962                serde_json::from_value(event.payload.clone()).ok()?;
963            let searchable = serde_json::to_string(&payload).ok()?.to_lowercase();
964            if !terms.is_empty() && !terms.iter().any(|term| searchable.contains(term)) {
965                return None;
966            }
967            let rejected = payload
968                .rejected
969                .iter()
970                .map(|item| format!("{} — {}", item.hypothesis, item.reason))
971                .collect::<Vec<_>>();
972            let text = format!(
973                "checkpoint: hypotheses=[{}]; rejected=[{}]; open=[{}]; next={}",
974                payload.hypotheses.join(" | "),
975                rejected.join(" | "),
976                payload.open.join(" | "),
977                payload.next,
978            );
979            Some(NoteHit {
980                event_id: event.event_id,
981                text,
982                ts: event.ts,
983                branch: event.branch,
984            })
985        })
986        .take(opts.limit)
987        .collect())
988}
989
990/// Return the affected_paths carried by each decision in `result.decisions`,
991/// looked up from the ledger by event_id. Position-aligned with the input;
992/// missing/unreadable rows return empty Vec (best-effort).
993pub fn affected_paths_for_hits(ledger: &Ledger, hits: &[DecisionHit]) -> Vec<Vec<String>> {
994    hits.iter()
995        .map(|h| {
996            ledger
997                .get_decision_by_event_id(&h.event_id)
998                .ok()
999                .flatten()
1000                .map(|row| row.affected_paths)
1001                .unwrap_or_default()
1002        })
1003        .collect()
1004}
1005
1006// Decision helpers centralized in edda_core::decision.
1007
1008// ── Tests ────────────────────────────────────────────────────────────
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use edda_core::event::{
1014        finalize_event, new_checkpoint_event, new_note_event, CheckpointPayload, RejectedHypothesis,
1015    };
1016    use edda_core::Provenance;
1017    use edda_ledger::ledger::{init_branches_json, init_head, init_workspace};
1018    use edda_ledger::paths::EddaPaths;
1019    use std::sync::atomic::{AtomicU64, Ordering};
1020
1021    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
1022
1023    struct TestLedger(Ledger);
1024
1025    impl std::ops::Deref for TestLedger {
1026        type Target = Ledger;
1027
1028        fn deref(&self) -> &Self::Target {
1029            &self.0
1030        }
1031    }
1032
1033    impl TestLedger {
1034        fn append_event(&self, event: &Event) -> anyhow::Result<()> {
1035            let mut chained = event.clone();
1036            chained.parent_hash = self.0.last_event_hash()?;
1037            finalize_event(&mut chained)?;
1038            self.0.append_event(&chained)
1039        }
1040    }
1041
1042    fn setup() -> (std::path::PathBuf, TestLedger) {
1043        let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
1044        let tmp = std::env::temp_dir().join(format!("edda_ask_test_{}_{n}", std::process::id()));
1045        let _ = std::fs::remove_dir_all(&tmp);
1046        let paths = EddaPaths::discover(&tmp);
1047        init_workspace(&paths).unwrap();
1048        init_head(&paths, "main").unwrap();
1049        init_branches_json(&paths, "main").unwrap();
1050        let ledger = TestLedger(Ledger::open(&tmp).unwrap());
1051        (tmp, ledger)
1052    }
1053
1054    /// A finished task's receipt is the fleet's answer to "what shipped about X,
1055    /// verified how" — and before GH-404 `ask` had no Tasks section at all, so
1056    /// it never surfaced. Matching on the receipt is the point: that is where
1057    /// the verification lives.
1058    #[test]
1059    fn ask_surfaces_a_task_whose_receipt_matches_the_query() {
1060        let (_tmp, ledger) = setup();
1061
1062        let created =
1063            edda_core::event::new_task_created_event(&edda_core::event::TaskCreatedParams {
1064                branch: "main",
1065                parent_hash: None,
1066                task_id: 7,
1067                title: "run the onboarding drill",
1068                assignee: None,
1069                agent_kind: None,
1070                after: &[],
1071                plan_id: None,
1072                work_unit_ref: None,
1073                brief_ref: None,
1074                idempotency_key: None,
1075                scope_paths: &[],
1076            })
1077            .unwrap();
1078        ledger.append_event(&created).unwrap();
1079        let done = edda_core::event::new_task_done_event(
1080            "main",
1081            None,
1082            7,
1083            "drill passed: 9/9 green, artifact in dist/drill.json",
1084            &["dist/drill.json".to_string()],
1085        )
1086        .unwrap();
1087        ledger.append_event(&done).unwrap();
1088
1089        // A second task that does NOT mention "drill". Without it, the test
1090        // could not tell a working filter from one that returns every task —
1091        // with a single task, "the match is present" and "everything is
1092        // present" are the same assertion.
1093        let other =
1094            edda_core::event::new_task_created_event(&edda_core::event::TaskCreatedParams {
1095                branch: "main",
1096                parent_hash: None,
1097                task_id: 8,
1098                title: "write the changelog",
1099                assignee: None,
1100                agent_kind: None,
1101                after: &[],
1102                plan_id: None,
1103                work_unit_ref: None,
1104                brief_ref: None,
1105                idempotency_key: None,
1106                scope_paths: &[],
1107            })
1108            .unwrap();
1109        ledger.append_event(&other).unwrap();
1110
1111        let opts = AskOptions {
1112            limit: 10,
1113            ..Default::default()
1114        };
1115        let result = ask(&ledger, "drill", &opts, None).unwrap();
1116
1117        assert_eq!(
1118            result.tasks.len(),
1119            1,
1120            "only the drill task matches — the changelog task must be filtered out: {:?}",
1121            result.tasks
1122        );
1123        let hit = &result.tasks[0];
1124        assert_eq!(hit.task_id, 7);
1125        assert!(hit.title.contains("drill"), "title: {}", hit.title);
1126        assert_eq!(hit.status, "done");
1127        assert!(
1128            hit.receipt.as_deref().unwrap_or("").contains("9/9 green"),
1129            "the receipt is the point: {:?}",
1130            hit.receipt
1131        );
1132        assert_eq!(hit.evidence_paths, vec!["dist/drill.json".to_string()]);
1133    }
1134
1135    #[test]
1136    fn ask_recalls_checkpoint_judgment_state() {
1137        let (_tmp, ledger) = setup();
1138        let checkpoint = CheckpointPayload {
1139            hypotheses: vec!["cache invalidation is the cause".to_string()],
1140            rejected: vec![RejectedHypothesis {
1141                hypothesis: "database corruption".to_string(),
1142                reason: "integrity check passes".to_string(),
1143            }],
1144            open: vec!["confirm the next rebuild".to_string()],
1145            next: "run the rebuild check".to_string(),
1146        };
1147        let event = new_checkpoint_event("main", None, "agent", &checkpoint).unwrap();
1148        ledger.append_event(&event).unwrap();
1149
1150        let result = ask(&ledger, "rebuild", &AskOptions::default(), None).unwrap();
1151
1152        assert_eq!(result.related_notes.len(), 1);
1153        assert!(result.related_notes[0]
1154            .text
1155            .contains("next=run the rebuild check"));
1156        assert!(result.related_notes[0]
1157            .text
1158            .contains("integrity check passes"));
1159    }
1160
1161    fn make_decision(
1162        branch: &str,
1163        key: &str,
1164        value: &str,
1165        reason: Option<&str>,
1166        supersedes: Option<&str>,
1167    ) -> Event {
1168        let text = match reason {
1169            Some(r) => format!("{key}: {value} — {r}"),
1170            None => format!("{key}: {value}"),
1171        };
1172        let tags = vec!["decision".to_string()];
1173        let mut event = new_note_event(branch, None, "system", &text, &tags).unwrap();
1174        let decision_obj = match reason {
1175            Some(r) => serde_json::json!({"key": key, "value": value, "reason": r}),
1176            None => serde_json::json!({"key": key, "value": value}),
1177        };
1178        event.payload["decision"] = decision_obj;
1179        if let Some(target) = supersedes {
1180            event.refs.provenance.push(Provenance {
1181                target: target.to_string(),
1182                rel: "supersedes".to_string(),
1183                note: Some(format!("key '{key}' re-decided")),
1184            });
1185        }
1186        finalize_event(&mut event).unwrap();
1187        event
1188    }
1189
1190    fn make_commit(branch: &str, title: &str, purpose: &str, evidence: &[&str]) -> Event {
1191        use edda_core::event::finalize_event;
1192        let payload = serde_json::json!({
1193            "title": title,
1194            "purpose": purpose,
1195            "sha": "abc123",
1196        });
1197        let mut event = Event {
1198            event_id: format!("evt_{}", ulid::Ulid::new().to_string().to_lowercase()),
1199            ts: time_now(),
1200            event_type: "commit".to_string(),
1201            branch: branch.to_string(),
1202            parent_hash: None,
1203            hash: String::new(),
1204            payload,
1205            refs: edda_core::Refs {
1206                events: evidence.iter().map(|s| s.to_string()).collect(),
1207                ..Default::default()
1208            },
1209            schema_version: 1,
1210            digests: vec![],
1211            event_family: None,
1212            event_level: None,
1213        };
1214        finalize_event(&mut event).unwrap();
1215        event
1216    }
1217
1218    fn make_note(branch: &str, text: &str) -> Event {
1219        new_note_event(branch, None, "user", text, &[]).unwrap()
1220    }
1221
1222    fn time_now() -> String {
1223        let now = time::OffsetDateTime::now_utc();
1224        now.format(&time::format_description::well_known::Rfc3339)
1225            .expect("rfc3339")
1226    }
1227
1228    // ── detect_input_type tests ──────────────────────────────────────
1229
1230    #[test]
1231    fn detect_exact_key() {
1232        let domains = vec!["db".into(), "auth".into()];
1233        assert_eq!(
1234            detect_input_type("db.engine", &domains),
1235            InputType::ExactKey("db.engine".into())
1236        );
1237    }
1238
1239    #[test]
1240    fn detect_domain() {
1241        let domains = vec!["db".into(), "auth".into()];
1242        assert_eq!(
1243            detect_input_type("db", &domains),
1244            InputType::Domain("db".into())
1245        );
1246    }
1247
1248    #[test]
1249    fn detect_domain_case_insensitive() {
1250        let domains = vec!["db".into()];
1251        assert_eq!(
1252            detect_input_type("DB", &domains),
1253            InputType::Domain("DB".into())
1254        );
1255    }
1256
1257    #[test]
1258    fn detect_keyword() {
1259        let domains = vec!["db".into()];
1260        assert_eq!(
1261            detect_input_type("postgres", &domains),
1262            InputType::Keyword("postgres".into())
1263        );
1264    }
1265
1266    #[test]
1267    fn detect_overview() {
1268        assert_eq!(detect_input_type("", &[]), InputType::Overview);
1269        assert_eq!(detect_input_type("  ", &[]), InputType::Overview);
1270    }
1271
1272    // ── ask() tests ──────────────────────────────────────────────────
1273
1274    #[test]
1275    fn ask_exact_key() {
1276        let (tmp, ledger) = setup();
1277        let d1 = make_decision("main", "db.engine", "sqlite", Some("MVP"), None);
1278        let d1_id = d1.event_id.clone();
1279        ledger.append_event(&d1).unwrap();
1280
1281        let d2 = make_decision("main", "db.engine", "postgres", Some("JSONB"), Some(&d1_id));
1282        ledger.append_event(&d2).unwrap();
1283
1284        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1285        assert_eq!(result.input_type, "exact_key");
1286        // decisions = only active (default)
1287        assert_eq!(result.decisions.len(), 1);
1288        assert_eq!(result.decisions[0].value, "postgres");
1289        // timeline = both
1290        assert_eq!(result.timeline.len(), 2);
1291
1292        let _ = std::fs::remove_dir_all(&tmp);
1293    }
1294
1295    #[test]
1296    fn ask_domain() {
1297        let (tmp, ledger) = setup();
1298        ledger
1299            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1300            .unwrap();
1301        ledger
1302            .append_event(&make_decision("main", "db.pool", "10", None, None))
1303            .unwrap();
1304        ledger
1305            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1306            .unwrap();
1307
1308        let result = ask(&ledger, "db", &AskOptions::default(), None).unwrap();
1309        assert_eq!(result.input_type, "domain");
1310        assert_eq!(result.decisions.len(), 2);
1311        assert!(result.timeline.len() >= 2);
1312
1313        let _ = std::fs::remove_dir_all(&tmp);
1314    }
1315
1316    #[test]
1317    fn ask_keyword() {
1318        let (tmp, ledger) = setup();
1319        ledger
1320            .append_event(&make_decision(
1321                "main",
1322                "db.engine",
1323                "postgres",
1324                Some("JSONB"),
1325                None,
1326            ))
1327            .unwrap();
1328        ledger
1329            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1330            .unwrap();
1331
1332        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1333        assert_eq!(result.input_type, "keyword");
1334        assert_eq!(result.decisions.len(), 1);
1335        assert_eq!(result.decisions[0].key, "db.engine");
1336
1337        let _ = std::fs::remove_dir_all(&tmp);
1338    }
1339
1340    #[test]
1341    fn ask_overview() {
1342        let (tmp, ledger) = setup();
1343        ledger
1344            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1345            .unwrap();
1346        ledger
1347            .append_event(&make_decision("main", "auth.method", "JWT", None, None))
1348            .unwrap();
1349
1350        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1351        assert_eq!(result.input_type, "overview");
1352        assert_eq!(result.decisions.len(), 2);
1353
1354        let _ = std::fs::remove_dir_all(&tmp);
1355    }
1356
1357    #[test]
1358    fn ask_with_transcript_callback() {
1359        let (tmp, ledger) = setup();
1360        ledger
1361            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1362            .unwrap();
1363
1364        let callback = |_query: &str, _limit: usize| -> Vec<ConversationHit> {
1365            vec![ConversationHit {
1366                doc_id: "t1".into(),
1367                session_id: "s1".into(),
1368                ts: "2026-02-14T10:00:00Z".into(),
1369                snippet: "discussed postgres JSONB".into(),
1370                rank: 5.0,
1371            }]
1372        };
1373
1374        let result = ask(&ledger, "postgres", &AskOptions::default(), Some(&callback)).unwrap();
1375        assert_eq!(result.conversations.len(), 1);
1376        assert_eq!(result.conversations[0].snippet, "discussed postgres JSONB");
1377
1378        let _ = std::fs::remove_dir_all(&tmp);
1379    }
1380
1381    #[test]
1382    fn ask_without_transcript_callback() {
1383        let (tmp, ledger) = setup();
1384        ledger
1385            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1386            .unwrap();
1387
1388        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1389        assert!(result.conversations.is_empty());
1390
1391        let _ = std::fs::remove_dir_all(&tmp);
1392    }
1393
1394    // ── Related events tests ─────────────────────────────────────────
1395
1396    #[test]
1397    fn find_related_commits_evidence_chain() {
1398        let (tmp, ledger) = setup();
1399        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1400        let d1_id = d1.event_id.clone();
1401        ledger.append_event(&d1).unwrap();
1402
1403        let c1 = make_commit(
1404            "main",
1405            "feat: migrate to postgres",
1406            "db migration",
1407            &[&d1_id],
1408        );
1409        ledger.append_event(&c1).unwrap();
1410
1411        let result = ask(&ledger, "db.engine", &AskOptions::default(), None).unwrap();
1412        assert_eq!(result.related_commits.len(), 1);
1413        assert_eq!(result.related_commits[0].match_type, "evidence");
1414
1415        let _ = std::fs::remove_dir_all(&tmp);
1416    }
1417
1418    #[test]
1419    fn find_related_commits_title_match() {
1420        let (tmp, ledger) = setup();
1421        ledger
1422            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1423            .unwrap();
1424
1425        // Commit not linked via evidence, but title matches
1426        let c1 = make_commit("main", "fix: postgres connection pool", "pool fix", &[]);
1427        ledger.append_event(&c1).unwrap();
1428
1429        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1430        assert!(result
1431            .related_commits
1432            .iter()
1433            .any(|c| c.match_type == "title"));
1434
1435        let _ = std::fs::remove_dir_all(&tmp);
1436    }
1437
1438    #[test]
1439    fn find_related_notes_text_match() {
1440        let (tmp, ledger) = setup();
1441        ledger
1442            .append_event(&make_decision("main", "db.engine", "postgres", None, None))
1443            .unwrap();
1444
1445        // Regular note (not a decision)
1446        ledger
1447            .append_event(&make_note(
1448                "main",
1449                "discussed mysql but rejected for licensing",
1450            ))
1451            .unwrap();
1452
1453        let result = ask(&ledger, "mysql", &AskOptions::default(), None).unwrap();
1454        assert_eq!(result.related_notes.len(), 1);
1455        assert!(result.related_notes[0].text.contains("mysql"));
1456
1457        let _ = std::fs::remove_dir_all(&tmp);
1458    }
1459
1460    #[test]
1461    fn find_related_notes_excludes_decision_notes() {
1462        let (tmp, ledger) = setup();
1463        ledger
1464            .append_event(&make_decision(
1465                "main",
1466                "db.engine",
1467                "postgres",
1468                Some("JSONB"),
1469                None,
1470            ))
1471            .unwrap();
1472
1473        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1474        // Decision note should NOT appear in related_notes
1475        assert!(result.related_notes.is_empty());
1476
1477        let _ = std::fs::remove_dir_all(&tmp);
1478    }
1479
1480    #[test]
1481    fn ask_branch_filter() {
1482        let (tmp, ledger) = setup();
1483        ledger
1484            .append_event(&make_decision(
1485                "main",
1486                "db.engine",
1487                "postgres",
1488                Some("prod"),
1489                None,
1490            ))
1491            .unwrap();
1492        ledger
1493            .append_event(&make_decision(
1494                "dev",
1495                "db.engine",
1496                "sqlite",
1497                Some("dev speed"),
1498                None,
1499            ))
1500            .unwrap();
1501
1502        // No branch filter → both
1503        let result = ask(&ledger, "", &AskOptions::default(), None).unwrap();
1504        assert_eq!(result.decisions.len(), 2);
1505
1506        // Filter to dev → only sqlite
1507        let opts = AskOptions {
1508            branch: Some("dev".into()),
1509            ..Default::default()
1510        };
1511        let result = ask(&ledger, "", &opts, None).unwrap();
1512        assert_eq!(result.decisions.len(), 1);
1513        assert_eq!(result.decisions[0].value, "sqlite");
1514
1515        let _ = std::fs::remove_dir_all(&tmp);
1516    }
1517
1518    #[test]
1519    fn format_human_contains_sections() {
1520        let result = AskResult {
1521            query: "postgres".into(),
1522            input_type: "keyword".into(),
1523            decisions: vec![DecisionHit {
1524                event_id: "e1".into(),
1525                key: "db.engine".into(),
1526                value: "postgres".into(),
1527                reason: "JSONB".into(),
1528                domain: "db".into(),
1529                branch: "main".into(),
1530                ts: "2026-02-15".into(),
1531                is_active: true,
1532                tags: vec![],
1533                village_id: None,
1534                staleness: None,
1535            }],
1536            timeline: vec![],
1537            related_commits: vec![CommitHit {
1538                event_id: "c1".into(),
1539                title: "feat: migrate".into(),
1540                purpose: "migration".into(),
1541                ts: "2026-02-15".into(),
1542                branch: "main".into(),
1543                match_type: "evidence".into(),
1544            }],
1545            related_notes: vec![],
1546            conversations: vec![],
1547            tasks: vec![],
1548            dependents: vec![],
1549            override_risk: None,
1550        };
1551
1552        let output = format_human(&result);
1553        assert!(output.contains("Decisions"));
1554        assert!(output.contains("postgres"));
1555        assert!(output.contains("Related Commits"));
1556        assert!(output.contains("feat: migrate"));
1557    }
1558
1559    #[test]
1560    fn find_related_notes_excludes_session_digests() {
1561        let (tmp, ledger) = setup();
1562        // Create a session digest note containing a keyword
1563        let tags = vec!["session_digest".to_string()];
1564        let mut digest = new_note_event(
1565            "main",
1566            None,
1567            "system",
1568            "discussed postgres migration",
1569            &tags,
1570        )
1571        .unwrap();
1572        digest.payload["session_stats"] = serde_json::json!({
1573            "tool_calls": 10,
1574            "tasks_snapshot": [{"subject": "Fix postgres pool", "status": "completed"}],
1575        });
1576        finalize_event(&mut digest).unwrap();
1577        ledger.append_event(&digest).unwrap();
1578
1579        let result = ask(&ledger, "postgres", &AskOptions::default(), None).unwrap();
1580        // Session digest note should NOT appear in related_notes
1581        assert!(
1582            result.related_notes.is_empty(),
1583            "session digest should be filtered: {:?}",
1584            result.related_notes
1585        );
1586
1587        let _ = std::fs::remove_dir_all(&tmp);
1588    }
1589
1590    #[test]
1591    fn format_human_truncates_long_notes() {
1592        let long_text = "a".repeat(200);
1593        let result = AskResult {
1594            query: "test".into(),
1595            input_type: "keyword".into(),
1596            decisions: vec![],
1597            timeline: vec![],
1598            related_commits: vec![],
1599            related_notes: vec![NoteHit {
1600                event_id: "n1".into(),
1601                text: long_text,
1602                ts: "2026-02-26".into(),
1603                branch: "main".into(),
1604            }],
1605            conversations: vec![],
1606            tasks: vec![],
1607            dependents: vec![],
1608            override_risk: None,
1609        };
1610
1611        let output = format_human(&result);
1612        assert!(output.contains("..."), "long note should be truncated");
1613        // Should not contain the full 200-char string
1614        assert!(
1615            !output.contains(&"a".repeat(200)),
1616            "should not contain full text"
1617        );
1618    }
1619
1620    #[test]
1621    fn format_human_empty_result() {
1622        let result = AskResult {
1623            query: "nonexistent".into(),
1624            input_type: "keyword".into(),
1625            decisions: vec![],
1626            timeline: vec![],
1627            related_commits: vec![],
1628            related_notes: vec![],
1629            conversations: vec![],
1630            tasks: vec![],
1631            dependents: vec![],
1632            override_risk: None,
1633        };
1634
1635        let output = format_human(&result);
1636        assert!(output.contains("No results found"));
1637    }
1638
1639    // ── Impact analysis tests ───────────────────────────────────────
1640
1641    #[test]
1642    fn impact_no_dependents() {
1643        let (tmp, ledger) = setup();
1644        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1645        ledger.append_event(&d1).unwrap();
1646
1647        let opts = AskOptions {
1648            impact: true,
1649            ..Default::default()
1650        };
1651        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1652        assert!(result.dependents.is_empty());
1653        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1654        assert_eq!(result.override_risk.as_ref().unwrap().dependent_count, 0);
1655
1656        let _ = std::fs::remove_dir_all(&tmp);
1657    }
1658
1659    #[test]
1660    fn impact_single_explicit_dep() {
1661        let (tmp, ledger) = setup();
1662        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1663        ledger.append_event(&d1).unwrap();
1664
1665        let d2 = make_decision("main", "db.schema", "JSONB", Some("needs postgres"), None);
1666        ledger.append_event(&d2).unwrap();
1667
1668        // db.schema depends on db.engine (explicit)
1669        ledger
1670            .insert_dep("db.schema", "db.engine", "explicit", None)
1671            .unwrap();
1672
1673        let opts = AskOptions {
1674            impact: true,
1675            ..Default::default()
1676        };
1677        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1678        assert_eq!(result.dependents.len(), 1);
1679        assert_eq!(result.dependents[0].key, "db.schema");
1680        assert_eq!(result.dependents[0].dep_type, "explicit");
1681        assert_eq!(result.dependents[0].depth, 1);
1682        // 1 explicit direct dep => score = 3 => MEDIUM
1683        assert_eq!(result.override_risk.as_ref().unwrap().level, "MEDIUM");
1684
1685        let _ = std::fs::remove_dir_all(&tmp);
1686    }
1687
1688    #[test]
1689    fn impact_multiple_auto_domain() {
1690        let (tmp, ledger) = setup();
1691        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1692        ledger.append_event(&d1).unwrap();
1693        let d2 = make_decision("main", "db.pool", "10", None, None);
1694        ledger.append_event(&d2).unwrap();
1695        let d3 = make_decision("main", "db.timeout", "30s", None, None);
1696        ledger.append_event(&d3).unwrap();
1697
1698        // auto_domain deps
1699        ledger
1700            .insert_dep("db.pool", "db.engine", "auto_domain", None)
1701            .unwrap();
1702        ledger
1703            .insert_dep("db.timeout", "db.engine", "auto_domain", None)
1704            .unwrap();
1705
1706        let opts = AskOptions {
1707            impact: true,
1708            ..Default::default()
1709        };
1710        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1711        assert_eq!(result.dependents.len(), 2);
1712        // 2 auto_domain direct deps => score = 2 => LOW
1713        assert_eq!(result.override_risk.as_ref().unwrap().level, "LOW");
1714
1715        let _ = std::fs::remove_dir_all(&tmp);
1716    }
1717
1718    #[test]
1719    fn impact_transitive_chain() {
1720        let (tmp, ledger) = setup();
1721        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1722        ledger.append_event(&d1).unwrap();
1723        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1724        ledger.append_event(&d2).unwrap();
1725        let d3 = make_decision("main", "api.format", "JSON", None, None);
1726        ledger.append_event(&d3).unwrap();
1727
1728        // db.schema -> db.engine, api.format -> db.schema
1729        ledger
1730            .insert_dep("db.schema", "db.engine", "explicit", None)
1731            .unwrap();
1732        ledger
1733            .insert_dep("api.format", "db.schema", "explicit", None)
1734            .unwrap();
1735
1736        let opts = AskOptions {
1737            impact: true,
1738            ..Default::default()
1739        };
1740        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1741        assert_eq!(result.dependents.len(), 2);
1742
1743        // Check depths
1744        let schema_hit = result
1745            .dependents
1746            .iter()
1747            .find(|d| d.key == "db.schema")
1748            .unwrap();
1749        assert_eq!(schema_hit.depth, 1);
1750        let api_hit = result
1751            .dependents
1752            .iter()
1753            .find(|d| d.key == "api.format")
1754            .unwrap();
1755        assert_eq!(api_hit.depth, 2);
1756
1757        let _ = std::fs::remove_dir_all(&tmp);
1758    }
1759
1760    #[test]
1761    fn impact_cross_domain() {
1762        let (tmp, ledger) = setup();
1763        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1764        ledger.append_event(&d1).unwrap();
1765        let d2 = make_decision("main", "db.schema", "JSONB", None, None);
1766        ledger.append_event(&d2).unwrap();
1767        let d3 = make_decision("main", "api.format", "JSON", None, None);
1768        ledger.append_event(&d3).unwrap();
1769        let d4 = make_decision("main", "cache.strategy", "redis", None, None);
1770        ledger.append_event(&d4).unwrap();
1771
1772        // All depend on db.engine via explicit
1773        ledger
1774            .insert_dep("db.schema", "db.engine", "explicit", None)
1775            .unwrap();
1776        ledger
1777            .insert_dep("api.format", "db.engine", "explicit", None)
1778            .unwrap();
1779        ledger
1780            .insert_dep("cache.strategy", "db.engine", "explicit", None)
1781            .unwrap();
1782
1783        let opts = AskOptions {
1784            impact: true,
1785            ..Default::default()
1786        };
1787        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1788        assert_eq!(result.dependents.len(), 3);
1789        // 3 explicit direct (9) + cross_domain: 3 domains × 2 (6) => score = 15 => HIGH
1790        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1791
1792        let _ = std::fs::remove_dir_all(&tmp);
1793    }
1794
1795    #[test]
1796    fn impact_high_risk_scenario() {
1797        let (tmp, ledger) = setup();
1798        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1799        ledger.append_event(&d1).unwrap();
1800
1801        // Create 4 explicit dependents + 1 transitive
1802        for (i, key) in ["db.schema", "db.pool", "api.format", "auth.store"]
1803            .iter()
1804            .enumerate()
1805        {
1806            let d = make_decision("main", key, &format!("val{i}"), None, None);
1807            ledger.append_event(&d).unwrap();
1808            ledger
1809                .insert_dep(key, "db.engine", "explicit", None)
1810                .unwrap();
1811        }
1812        // Transitive: cache.ttl -> api.format -> db.engine
1813        let d_cache = make_decision("main", "cache.ttl", "60s", None, None);
1814        ledger.append_event(&d_cache).unwrap();
1815        ledger
1816            .insert_dep("cache.ttl", "api.format", "explicit", None)
1817            .unwrap();
1818
1819        let opts = AskOptions {
1820            impact: true,
1821            ..Default::default()
1822        };
1823        let result = ask(&ledger, "db.engine", &opts, None).unwrap();
1824        assert_eq!(result.dependents.len(), 5);
1825        assert_eq!(result.override_risk.as_ref().unwrap().level, "HIGH");
1826
1827        let _ = std::fs::remove_dir_all(&tmp);
1828    }
1829
1830    #[test]
1831    fn impact_domain_query() {
1832        let (tmp, ledger) = setup();
1833        let d1 = make_decision("main", "db.engine", "postgres", None, None);
1834        ledger.append_event(&d1).unwrap();
1835        let d2 = make_decision("main", "db.pool", "10", None, None);
1836        ledger.append_event(&d2).unwrap();
1837
1838        // api.format depends on db.engine
1839        let d3 = make_decision("main", "api.format", "JSON", None, None);
1840        ledger.append_event(&d3).unwrap();
1841        ledger
1842            .insert_dep("api.format", "db.engine", "explicit", None)
1843            .unwrap();
1844
1845        let opts = AskOptions {
1846            impact: true,
1847            ..Default::default()
1848        };
1849        let result = ask(&ledger, "db", &opts, None).unwrap();
1850        assert_eq!(result.input_type, "domain");
1851        // api.format is a dependent of db.engine
1852        assert!(
1853            result.dependents.iter().any(|d| d.key == "api.format"),
1854            "domain impact should find api.format as dependent"
1855        );
1856        assert!(result.override_risk.is_some());
1857
1858        let _ = std::fs::remove_dir_all(&tmp);
1859    }
1860
1861    #[test]
1862    fn impact_flag_ignored_for_keyword() {
1863        let (tmp, ledger) = setup();
1864        let d1 = make_decision("main", "db.engine", "postgres", Some("JSONB"), None);
1865        ledger.append_event(&d1).unwrap();
1866
1867        let opts = AskOptions {
1868            impact: true,
1869            ..Default::default()
1870        };
1871        let result = ask(&ledger, "postgres", &opts, None).unwrap();
1872        assert_eq!(result.input_type, "keyword");
1873        // Impact should be empty for keyword queries
1874        assert!(result.dependents.is_empty());
1875        assert!(result.override_risk.is_none());
1876
1877        let _ = std::fs::remove_dir_all(&tmp);
1878    }
1879
1880    #[test]
1881    fn ask_keyword_semantic_similarity() {
1882        let (tmp, ledger) = setup();
1883        ledger
1884            .append_event(&make_decision(
1885                "main",
1886                "pricing.discount_policy",
1887                "daytime_revenue_shield",
1888                Some("avoid aggressive daytime markdowns"),
1889                None,
1890            ))
1891            .unwrap();
1892        ledger
1893            .append_event(&make_decision(
1894                "main",
1895                "inventory.reorder_buffer",
1896                "40_percent",
1897                Some("protect stock for midnight spikes"),
1898                None,
1899            ))
1900            .unwrap();
1901
1902        let query = "similar daytime discount outcome for chiefs";
1903        let result = ask(&ledger, query, &AskOptions::default(), None).unwrap();
1904
1905        assert_eq!(result.input_type, "keyword");
1906        assert!(
1907            result
1908                .decisions
1909                .iter()
1910                .any(|d| d.key == "pricing.discount_policy"),
1911            "semantic search should retrieve discount precedent"
1912        );
1913
1914        let _ = std::fs::remove_dir_all(&tmp);
1915    }
1916
1917    #[test]
1918    fn format_human_with_impact() {
1919        let result = AskResult {
1920            query: "db.engine".into(),
1921            input_type: "exact_key".into(),
1922            decisions: vec![DecisionHit {
1923                event_id: "e1".into(),
1924                key: "db.engine".into(),
1925                value: "postgres".into(),
1926                reason: "JSONB".into(),
1927                domain: "db".into(),
1928                branch: "main".into(),
1929                ts: "2026-02-15".into(),
1930                is_active: true,
1931                tags: vec![],
1932                village_id: None,
1933                staleness: None,
1934            }],
1935            timeline: vec![],
1936            related_commits: vec![],
1937            related_notes: vec![],
1938            conversations: vec![],
1939            tasks: vec![],
1940            dependents: vec![
1941                DependentHit {
1942                    key: "db.schema".into(),
1943                    value: "JSONB".into(),
1944                    dep_type: "explicit".into(),
1945                    depth: 1,
1946                },
1947                DependentHit {
1948                    key: "api.format".into(),
1949                    value: "JSON".into(),
1950                    dep_type: "auto_domain".into(),
1951                    depth: 2,
1952                },
1953            ],
1954            override_risk: Some(OverrideRisk {
1955                level: "HIGH".to_string(),
1956                dependent_count: 2,
1957                suggestion: Some("建議覆蓋順序: api.format → db.schema".into()),
1958            }),
1959        };
1960
1961        let output = format_human(&result);
1962        assert!(
1963            output.contains("Dependents"),
1964            "should have Dependents section"
1965        );
1966        assert!(
1967            output.contains("depth 1"),
1968            "should show depth for dependents"
1969        );
1970        assert!(
1971            output.contains("depth 2"),
1972            "should show depth for transitive"
1973        );
1974        assert!(
1975            output.contains("Override Risk"),
1976            "should have Override Risk section"
1977        );
1978        assert!(output.contains("HIGH"), "should show HIGH risk level");
1979        assert!(
1980            output.contains("建議覆蓋順序"),
1981            "should show override order suggestion"
1982        );
1983    }
1984
1985    #[test]
1986    fn ask_filters_by_village_id() {
1987        let (_tmp, ledger) = setup();
1988
1989        // Create two decisions: one with village_id, one without
1990        let mut ev1 = make_decision("main", "db.engine", "sqlite", Some("embedded"), None);
1991        ev1.payload["decision"]["village_id"] = serde_json::json!("village-a");
1992        finalize_event(&mut ev1).unwrap();
1993        ledger.append_event(&ev1).unwrap();
1994
1995        let ev2 = make_decision("main", "cache.ttl", "3600", Some("perf"), None);
1996        ledger.append_event(&ev2).unwrap();
1997
1998        // Without village filter: should find both
1999        let opts_all = AskOptions::default();
2000        let result_all = ask(&ledger, "db OR cache", &opts_all, None).unwrap();
2001        assert!(
2002            result_all.decisions.len() >= 2,
2003            "expected at least 2 decisions without village filter, got {}",
2004            result_all.decisions.len()
2005        );
2006
2007        // With village filter: should find only village-a
2008        let opts_village = AskOptions {
2009            village_id: Some("village-a".to_string()),
2010            ..Default::default()
2011        };
2012        let result_village = ask(&ledger, "db OR cache", &opts_village, None).unwrap();
2013        assert_eq!(
2014            result_village.decisions.len(),
2015            1,
2016            "expected 1 decision with village filter"
2017        );
2018        assert_eq!(result_village.decisions[0].key, "db.engine");
2019        assert_eq!(
2020            result_village.decisions[0].village_id.as_deref(),
2021            Some("village-a")
2022        );
2023
2024        // Non-existent village: should find none
2025        let opts_empty = AskOptions {
2026            village_id: Some("no-such-village".to_string()),
2027            ..Default::default()
2028        };
2029        let result_empty = ask(&ledger, "db OR cache", &opts_empty, None).unwrap();
2030        assert!(
2031            result_empty.decisions.is_empty(),
2032            "expected 0 decisions for non-existent village"
2033        );
2034    }
2035}