Skip to main content

edda_ask/
lib.rs

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