Skip to main content

rac_engine/
retrieve.rs

1//! Compound deterministic grounding retrieval (`decided retrieve`, ADR-113) — a
2//! port of `src/asdecided/services/retrieve.py` and `src/asdecided/services/scope.py` /
3//! `scope_paths.py` (the scope-binding channel) from the
4//! `grounding-retrieval-surface` branch (oracle `0.1.dev55+gf2091befd`).
5//! The ADR-033 response budget (serialization + truncation) lives in
6//! `crate::budget`.
7//!
8//! Landmines reproduced here:
9//! - Excerpts are Python character slices (`content[:share]`), over the file's
10//!   text read with universal newlines (`\r\n`/`\r` → `\n`); an unreadable or
11//!   non-UTF-8 file contributes an empty excerpt.
12//! - Payload/provenance key ORDER is Python dict insertion order: items are
13//!   `id, type, title, status, path, excerpt, provenance`; provenance keys in
14//!   first-set order (`channels` first, then whichever of `matching_entry`,
15//!   `superseded`, `evidence` was set first).
16//! - Scope binding matches `scope._entry_covers`: segment-aware globs compiled
17//!   exactly like `_glob_to_regex` (`*`/`?` within a segment, `**` across,
18//!   `**/` zero-or-more whole segments, `[...]` classes, `.`-collapse and
19//!   `..`-rejection in path normalisation).
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24use serde_json::{json, Map, Value};
25
26use crate::budget::py_slice_to;
27use crate::identity::artifact_identifier;
28use crate::pycompat::{py_casefold, py_strip, read_text_universal};
29use crate::relationships::{
30    classify_scope_entry, corpus_items, extract_relationships_full, normalized_scope_path,
31    relationships_from_corpus, CorpusItem, Relationship,
32};
33use crate::resolve::{
34    artifact_status, index_from_items, is_live_decision, is_retired_status, search_index,
35    IndexEntry, SearchResult,
36};
37
38// Defaults pinned by the grounding-retrieval-surface design.
39pub const DEFAULT_TOP_K: i64 = 5;
40
41const SUPERSEDES: &str = "supersedes";
42const DECISION_TYPE: &str = "decision";
43
44// Discovery channel names on the wire (pinned by the design).
45const CHANNEL_KEYWORD: &str = "keyword";
46const CHANNEL_SCOPE: &str = "scope";
47const CHANNEL_SUPERSEDES: &str = "supersedes";
48
49// ---------------------------------------------------------------------------
50// scope_paths.py — path normalisation, repository root (entry classification
51// is shared with relationships.rs: `classify_scope_entry` /
52// `normalized_scope_path`)
53// ---------------------------------------------------------------------------
54
55/// `PurePosixPath(text).parts` minus any root marker: empty and `.` segments
56/// collapse; the root marker (when the text is absolute) is returned apart.
57fn pure_posix_parts(text: &str) -> (Option<&'static str>, Vec<String>) {
58    let root = if text.starts_with('/') {
59        // POSIX: exactly two leading slashes are the special `//` root;
60        // one or three-plus collapse to `/`.
61        if text.starts_with("//") && !text.starts_with("///") {
62            Some("//")
63        } else {
64            Some("/")
65        }
66    } else {
67        None
68    };
69    let parts = text
70        .split('/')
71        .filter(|p| !p.is_empty() && *p != ".")
72        .map(str::to_string)
73        .collect();
74    (root, parts)
75}
76
77/// `repository_root(directory)` — nearest ancestor holding `.decided/config.yaml`,
78/// else the resolved directory itself.
79fn repository_root(directory: &str) -> PathBuf {
80    let resolved = Path::new(directory).canonicalize().unwrap_or_else(|_| {
81        // Python resolve() is non-strict; absolutize against the cwd.
82        std::env::current_dir()
83            .map(|c| c.join(directory))
84            .unwrap_or_else(|_| PathBuf::from(directory))
85    });
86    for candidate in resolved.ancestors() {
87        if candidate.join(".decided").join("config.yaml").is_file() {
88            return candidate.to_path_buf();
89        }
90    }
91    resolved
92}
93
94// ---------------------------------------------------------------------------
95// scope.py — the `_glob_to_regex` glob matcher (compiled, not regex-backed)
96// ---------------------------------------------------------------------------
97
98#[derive(Debug, Clone)]
99enum ClassItem {
100    Ch(char),
101    Range(char, char),
102    Digit,
103    NonDigit,
104    Word,
105    NonWord,
106    Space,
107    NonSpace,
108}
109
110#[derive(Debug, Clone)]
111enum GlobTok {
112    Lit(char),
113    /// `[^/]*`
114    Star,
115    /// `[^/]`
116    Q,
117    /// `(?:[^/]+/)*`
118    SegStar,
119    /// `.*` (any char except `\n`)
120    DotStar,
121    Class {
122        negated: bool,
123        items: Vec<ClassItem>,
124    },
125}
126
127/// Compile the pattern exactly as `_glob_to_regex` builds its regex.
128fn compile_glob(pattern: &str) -> Vec<GlobTok> {
129    let chars: Vec<char> = pattern.chars().collect();
130    let n = chars.len();
131    let mut out: Vec<GlobTok> = Vec::new();
132    let mut i = 0usize;
133    while i < n {
134        let c = chars[i];
135        if c == '*' {
136            if i + 1 < n && chars[i + 1] == '*' {
137                i += 2;
138                if i < n && chars[i] == '/' {
139                    i += 1;
140                    out.push(GlobTok::SegStar);
141                } else {
142                    out.push(GlobTok::DotStar);
143                }
144                continue;
145            }
146            out.push(GlobTok::Star);
147        } else if c == '?' {
148            out.push(GlobTok::Q);
149        } else if c == '[' {
150            let mut j = i + 1;
151            if j < n && (chars[j] == '!' || chars[j] == '^') {
152                j += 1;
153            }
154            if j < n && chars[j] == ']' {
155                j += 1;
156            }
157            while j < n && chars[j] != ']' {
158                j += 1;
159            }
160            if j >= n {
161                out.push(GlobTok::Lit('[')); // unterminated class → literal '['
162            } else {
163                let inner: Vec<char> = chars[i + 1..j].to_vec();
164                let (negated, body) = match inner.first() {
165                    Some('!') | Some('^') => (true, &inner[1..]),
166                    _ => (false, &inner[..]),
167                };
168                out.push(GlobTok::Class {
169                    negated,
170                    items: parse_class_items(body),
171                });
172                i = j + 1;
173                continue;
174            }
175        } else {
176            out.push(GlobTok::Lit(c));
177        }
178        i += 1;
179    }
180    out
181}
182
183/// Parse a regex character-class body (`a-z`, escapes, shorthands).
184fn parse_class_items(body: &[char]) -> Vec<ClassItem> {
185    let mut items: Vec<ClassItem> = Vec::new();
186    let mut k = 0usize;
187    let n = body.len();
188    while k < n {
189        // Resolve one class atom (an escaped char/shorthand or a literal).
190        let (atom, used, shorthand) = if body[k] == '\\' && k + 1 < n {
191            let e = body[k + 1];
192            let sh = match e {
193                'd' => Some(ClassItem::Digit),
194                'D' => Some(ClassItem::NonDigit),
195                'w' => Some(ClassItem::Word),
196                'W' => Some(ClassItem::NonWord),
197                's' => Some(ClassItem::Space),
198                'S' => Some(ClassItem::NonSpace),
199                _ => None,
200            };
201            (e, 2usize, sh)
202        } else {
203            (body[k], 1usize, None)
204        };
205        if let Some(sh) = shorthand {
206            items.push(sh);
207            k += used;
208            continue;
209        }
210        // Range: atom '-' atom (the '-' not last in the class body).
211        if k + used < n && body[k + used] == '-' && k + used + 1 < n {
212            let mut m = k + used + 1;
213            let hi = if body[m] == '\\' && m + 1 < n {
214                m += 1;
215                body[m]
216            } else {
217                body[m]
218            };
219            items.push(ClassItem::Range(atom, hi));
220            k = m + 1;
221            continue;
222        }
223        items.push(ClassItem::Ch(atom));
224        k += used;
225    }
226    items
227}
228
229fn class_matches(negated: bool, items: &[ClassItem], c: char) -> bool {
230    let hit = items.iter().any(|item| match item {
231        ClassItem::Ch(x) => c == *x,
232        ClassItem::Range(lo, hi) => (*lo..=*hi).contains(&c),
233        ClassItem::Digit => crate::pycompat::is_re_digit(c),
234        ClassItem::NonDigit => !crate::pycompat::is_re_digit(c),
235        ClassItem::Word => crate::pycompat::is_re_word(c),
236        ClassItem::NonWord => !crate::pycompat::is_re_word(c),
237        ClassItem::Space => py_re_space(c),
238        ClassItem::NonSpace => !py_re_space(c),
239    });
240    hit != negated
241}
242
243/// Python `re` `\s` over str patterns.
244fn py_re_space(c: char) -> bool {
245    matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c' | '\u{1c}'..='\u{1f}' | '\u{85}')
246        || crate::pycompat::py_is_space(c)
247}
248
249/// Backtracking matcher — boolean-equivalent to `re.match(regex + r"\Z", s)`.
250fn glob_match_at(toks: &[GlobTok], s: &[char]) -> bool {
251    let Some(tok) = toks.first() else {
252        return s.is_empty();
253    };
254    let rest = &toks[1..];
255    match tok {
256        GlobTok::Lit(c) => s.first() == Some(c) && glob_match_at(rest, &s[1..]),
257        GlobTok::Q => s.first().is_some_and(|&c| c != '/') && glob_match_at(rest, &s[1..]),
258        GlobTok::Star => {
259            let limit = s.iter().take_while(|&&c| c != '/').count();
260            (0..=limit).any(|k| glob_match_at(rest, &s[k..]))
261        }
262        GlobTok::DotStar => {
263            let limit = s.iter().take_while(|&&c| c != '\n').count();
264            (0..=limit).any(|k| glob_match_at(rest, &s[k..]))
265        }
266        GlobTok::SegStar => {
267            // zero segments:
268            if glob_match_at(rest, s) {
269                return true;
270            }
271            // one whole segment `[^/]+/`, then this token again:
272            let mut i = 0usize;
273            while i < s.len() && s[i] != '/' {
274                i += 1;
275            }
276            i > 0 && i < s.len() && glob_match_at(toks, &s[i + 1..])
277        }
278        GlobTok::Class { negated, items } => s
279            .first()
280            .is_some_and(|&c| class_matches(*negated, items, c))
281            && glob_match_at(rest, &s[1..]),
282    }
283}
284
285/// `_entry_covers(entry, query)`.
286fn entry_covers(entry: &str, query: &str) -> bool {
287    match classify_scope_entry(entry) {
288        "component" => false,
289        "glob" => {
290            let toks = compile_glob(py_strip(entry));
291            let q: Vec<char> = query.chars().collect();
292            glob_match_at(&toks, &q)
293        }
294        _ => match normalized_scope_path(entry) {
295            None => false,
296            Some(normalized) => {
297                query == normalized || query.starts_with(&format!("{normalized}/"))
298            }
299        },
300    }
301}
302
303/// `_normalize_query(path, root)` — POSIX repo-relative form, or None.
304fn normalize_query(path: &str, root: &Path) -> Option<String> {
305    let text = py_strip(path);
306    if text.is_empty() {
307        return None;
308    }
309    let (cand_root, mut cand_parts) = pure_posix_parts(text);
310    if cand_root.is_some() {
311        // PurePosixPath.relative_to(root.as_posix()) — parts-prefix check;
312        // a ValueError (differing root marker, or not nested) → None.
313        let root_posix = root.to_string_lossy().replace('\\', "/");
314        let (root_marker, root_parts) = pure_posix_parts(&root_posix);
315        if cand_root != root_marker
316            || cand_parts.len() < root_parts.len()
317            || cand_parts[..root_parts.len()] != root_parts[..]
318        {
319            return None; // outside the repository
320        }
321        cand_parts = cand_parts[root_parts.len()..].to_vec();
322    }
323    let mut parts: Vec<String> = Vec::new();
324    for part in cand_parts {
325        if part == ".." {
326            return None;
327        }
328        parts.push(part);
329    }
330    if parts.is_empty() {
331        None
332    } else {
333        Some(parts.join("/"))
334    }
335}
336
337// ---------------------------------------------------------------------------
338// derived_cache.py — scope rows + governing_decisions
339// ---------------------------------------------------------------------------
340
341/// One live decision's declared `## Applies To` scope (`ScopeRow`).
342#[derive(Clone)]
343pub struct ScopeRow {
344    pub id: String,
345    pub title: String,
346    pub status: String,
347    pub path: String,
348    pub scope_entries: Vec<String>,
349}
350
351/// `_scope_rows_from_corpus(entries)` — live decisions with declared scope.
352pub fn scope_rows_from_items(items: &[CorpusItem]) -> Vec<ScopeRow> {
353    let mut rows = Vec::new();
354    for item in items {
355        let Some(spec) = item.spec else { continue };
356        if spec.name != DECISION_TYPE || !is_live_decision(&item.artifact) {
357            continue;
358        }
359        // SCOPE_SECTIONS = ("applies to",) → snake key "applies_to".
360        let declared: Vec<String> = extract_relationships_full(&item.artifact, spec)
361            .into_iter()
362            .filter(|(section, _)| section == "applies_to")
363            .flat_map(|(_, refs)| refs)
364            .collect();
365        if declared.is_empty() {
366            continue;
367        }
368        rows.push(ScopeRow {
369            id: artifact_identifier(&item.artifact, Some(spec), &item.path),
370            title: item.artifact.product.title.clone().unwrap_or_default(),
371            status: artifact_status(&item.artifact),
372            path: item.path.clone(),
373            scope_entries: declared,
374        });
375    }
376    rows
377}
378
379/// One governing decision (`GoverningDecision` — the fields retrieve and
380/// `decided decisions-for` read).
381pub struct GoverningDecision {
382    pub id: String,
383    pub title: String,
384    pub status: String,
385    pub path: String,
386    pub matching_entry: String,
387}
388
389/// `governing_decisions(scope_rows, directory, path).decisions`.
390fn governing_decisions(rows: &[ScopeRow], directory: &str, path: &str) -> Vec<GoverningDecision> {
391    let root = repository_root(directory);
392    let Some(query) = normalize_query(path, &root) else {
393        return Vec::new();
394    };
395    let mut matches: Vec<GoverningDecision> = Vec::new();
396    for row in rows {
397        for declared in &row.scope_entries {
398            if entry_covers(declared, &query) {
399                matches.push(GoverningDecision {
400                    id: row.id.clone(),
401                    title: row.title.clone(),
402                    status: row.status.clone(),
403                    path: row.path.clone(),
404                    matching_entry: declared.clone(),
405                });
406                break;
407            }
408        }
409    }
410    matches.sort_by(|a, b| {
411        (py_casefold(&a.id), &a.path).cmp(&(py_casefold(&b.id), &b.path))
412    });
413    matches
414}
415
416/// `ScopeLookupResult` — the decisions governing a queried path. `query` is
417/// the POSIX repo-relative form when the path lies inside the repository,
418/// else the raw stripped input; an outside-repository or ungoverned path is
419/// a valid empty answer, never an error (REQ-004).
420pub struct ScopeLookupResult {
421    pub query: String,
422    pub in_repository: bool,
423    pub decisions: Vec<GoverningDecision>,
424}
425
426/// `decided.services.scope.decisions_for_path(directory, path, recursive)` — the
427/// CLI face of the scope lookup. Byte-identical to the derived-cache path
428/// (`governing_decisions`) for the same corpus and path; `recursive` threads
429/// the CLI's `--top-level` through the corpus walk (the MCP `find_decisions`
430/// path mode always walks recursively).
431pub fn decisions_for_path(directory: &str, path: &str, recursive: bool) -> ScopeLookupResult {
432    let root = repository_root(directory);
433    match normalize_query(path, &root) {
434        None => ScopeLookupResult {
435            query: py_strip(path).to_string(),
436            in_repository: false,
437            decisions: Vec::new(),
438        },
439        Some(query) => {
440            let items = corpus_items(directory, recursive);
441            let rows = scope_rows_from_items(&items);
442            ScopeLookupResult {
443                query,
444                in_repository: true,
445                decisions: governing_decisions(&rows, directory, path),
446            }
447        }
448    }
449}
450
451/// `ScopeLookupResult.to_dict()` — `{schema_version, query, in_repository,
452/// decisions}` in Python dict insertion order.
453pub fn scope_lookup_value(result: &ScopeLookupResult) -> Value {
454    let mut payload = Map::new();
455    payload.insert("schema_version".to_string(), json!("1"));
456    payload.insert("query".to_string(), json!(result.query));
457    payload.insert("in_repository".to_string(), json!(result.in_repository));
458    let decisions: Vec<Value> = result
459        .decisions
460        .iter()
461        .map(|d| {
462            let mut m = Map::new();
463            m.insert("id".to_string(), json!(d.id));
464            m.insert("title".to_string(), json!(d.title));
465            m.insert("status".to_string(), json!(d.status));
466            m.insert("path".to_string(), json!(d.path));
467            m.insert("matching_entry".to_string(), json!(d.matching_entry));
468            Value::Object(m)
469        })
470        .collect();
471    payload.insert("decisions".to_string(), Value::Array(decisions));
472    Value::Object(payload)
473}
474
475/// `decisions_for_path` over ALREADY-DERIVED scope rows (ADR-103): the
476/// read-model arm of the MCP `find_decisions` path mode, byte-identical to
477/// the fresh walk for the same corpus state.
478pub fn decisions_for_path_with_rows(
479    rows: &[ScopeRow],
480    directory: &str,
481    path: &str,
482) -> ScopeLookupResult {
483    let root = repository_root(directory);
484    match normalize_query(path, &root) {
485        None => ScopeLookupResult {
486            query: py_strip(path).to_string(),
487            in_repository: false,
488            decisions: Vec::new(),
489        },
490        Some(query) => ScopeLookupResult {
491            query,
492            in_repository: true,
493            decisions: governing_decisions(rows, directory, path),
494        },
495    }
496}
497
498/// `find_decisions` path mode (MCP surface): the `ScopeLookupResult.to_dict()`
499/// payload — `{schema_version, query, in_repository, decisions}` — for the live
500/// decisions whose declared `## Applies To` scope governs `path`. Additive
501/// wrapper over the same scope internals `retrieve_grounding` uses
502/// (`scope_rows_from_items` / `normalize_query` / `entry_covers`), byte-identical
503/// to `decided.services.derived_cache.governing_decisions(...).to_dict()`.
504pub fn find_decisions_path_payload(directory: &str, path: &str) -> Value {
505    scope_lookup_value(&decisions_for_path(directory, path, true))
506}
507
508// ---------------------------------------------------------------------------
509// retrieve.py — the compound grounding payload
510// ---------------------------------------------------------------------------
511
512/// `_successor_map(relationships)` — retired target path → sorted superseding
513/// source paths (resolved `supersedes` edges only).
514fn successor_map(relationships: &[Relationship]) -> HashMap<String, Vec<String>> {
515    let mut by_target: HashMap<String, Vec<String>> = HashMap::new();
516    for rel in relationships {
517        if rel.relationship == SUPERSEDES {
518            if let Some(target) = &rel.resolved_path {
519                by_target
520                    .entry(target.clone())
521                    .or_default()
522                    .push(rel.source_path.clone());
523            }
524        }
525    }
526    for sources in by_target.values_mut() {
527        sources.sort();
528        sources.dedup();
529    }
530    by_target
531}
532
533/// `_live_successors(path, by_target, is_retired, visited)`.
534fn live_successors(
535    path: &str,
536    by_target: &HashMap<String, Vec<String>>,
537    is_retired: &dyn Fn(&str) -> bool,
538    visited: &mut std::collections::HashSet<String>,
539) -> Vec<String> {
540    let mut out: Vec<String> = Vec::new();
541    let Some(sources) = by_target.get(path) else {
542        return out;
543    };
544    for source in sources {
545        if visited.contains(source) {
546            continue;
547        }
548        visited.insert(source.clone());
549        if is_retired(source) {
550            out.extend(live_successors(source, by_target, is_retired, visited));
551        } else {
552            out.push(source.clone());
553        }
554    }
555    out
556}
557
558/// One in-progress item (Python's per-path dict + its provenance dict).
559struct ItemBuilder {
560    id: String,
561    item_type: String,
562    title: Option<String>,
563    status: String,
564    path: String,
565    /// Provenance keys in insertion order.
566    provenance: Map<String, Value>,
567}
568
569#[allow(clippy::too_many_arguments)]
570fn add_item(
571    items: &mut Vec<ItemBuilder>,
572    index_of: &mut HashMap<String, usize>,
573    path: &str,
574    channel: &str,
575    item_id: &str,
576    item_type: &str,
577    title: Option<&str>,
578    status: &str,
579    matching_entry: Option<&str>,
580    superseded: Option<&str>,
581    evidence: Option<Value>,
582) {
583    let idx = match index_of.get(path) {
584        Some(&i) => i,
585        None => {
586            let mut provenance = Map::new();
587            provenance.insert("channels".to_string(), json!([]));
588            items.push(ItemBuilder {
589                id: item_id.to_string(),
590                item_type: item_type.to_string(),
591                title: title.map(str::to_string),
592                status: status.to_string(),
593                path: path.to_string(),
594                provenance,
595            });
596            index_of.insert(path.to_string(), items.len() - 1);
597            items.len() - 1
598        }
599    };
600    let provenance = &mut items[idx].provenance;
601    {
602        let channels = provenance
603            .get_mut("channels")
604            .and_then(Value::as_array_mut)
605            .expect("channels array");
606        if !channels.iter().any(|c| c.as_str() == Some(channel)) {
607            channels.push(json!(channel));
608        }
609    }
610    if let Some(entry) = matching_entry {
611        if !provenance.contains_key("matching_entry") {
612            provenance.insert("matching_entry".to_string(), json!(entry));
613        }
614    }
615    if let Some(replaced_id) = superseded {
616        if !provenance.contains_key("superseded") {
617            provenance.insert("superseded".to_string(), json!([]));
618        }
619        let replaced = provenance
620            .get_mut("superseded")
621            .and_then(Value::as_array_mut)
622            .expect("superseded array");
623        if !replaced.iter().any(|r| r.as_str() == Some(replaced_id)) {
624            replaced.push(json!(replaced_id));
625        }
626    }
627    if let Some(ev) = evidence {
628        if !provenance.contains_key("evidence") {
629            provenance.insert("evidence".to_string(), ev);
630        }
631    }
632}
633
634/// `retrieve_grounding(directory, task, scope, top_k, budget, live_only)` —
635/// the contract-shaped payload, pre-serialization (`budget::serialize` caps
636/// it).
637pub fn retrieve_grounding(
638    directory: &str,
639    task: &str,
640    scope: Option<&str>,
641    top_k: i64,
642    budget: i64,
643    live_only: bool,
644) -> Value {
645    let top_k = top_k.max(1);
646    let corpus = corpus_items(directory, true);
647    let entries: Vec<IndexEntry> = index_from_items(&corpus);
648    let entry_by_path: HashMap<&str, &IndexEntry> =
649        entries.iter().map(|e| (e.path.as_str(), e)).collect();
650    // Memoised per-call status reader: every queried path is a corpus path, so
651    // re-parsing its bytes yields exactly the already-parsed artifact.
652    let status_by_path: HashMap<&str, String> = corpus
653        .iter()
654        .map(|item| (item.path.as_str(), artifact_status(&item.artifact)))
655        .collect();
656    let status_of = |path: &str| -> String {
657        match status_by_path.get(path) {
658            Some(s) => s.clone(),
659            // Not part of the walked corpus: parse fresh, "" when unreadable.
660            None => {
661                if Path::new(path).is_file() {
662                    artifact_status(&crate::parse::parse_file(path))
663                } else {
664                    String::new()
665                }
666            }
667        }
668    };
669    let keyword = search_index(&entries, task, None, &[]);
670    let relationships = relationships_from_corpus(&corpus);
671    let scope_rows = scope_rows_from_items(&corpus);
672    retrieve_grounding_from_parts(
673        directory,
674        task,
675        scope,
676        top_k,
677        budget,
678        live_only,
679        keyword,
680        &scope_rows,
681        &relationships,
682        |path| entry_by_path.get(path).map(|entry| (*entry).clone()),
683        status_of,
684    )
685}
686
687/// Grounding over an already-derived mutation-window snapshot. Only matched,
688/// governing, and successor paths are read from disk for status/excerpts; the
689/// corpus itself is never walked or parsed again.
690pub fn retrieve_grounding_from_derived(
691    directory: &str,
692    task: &str,
693    scope: Option<&str>,
694    top_k: i64,
695    budget: i64,
696    live_only: bool,
697    derived: &crate::derived::DerivedIndex,
698) -> Value {
699    let keyword = search_index(&derived.index_entries, task, None, &[]);
700    let entry_by_path: HashMap<&str, &IndexEntry> = derived
701        .index_entries
702        .iter()
703        .map(|entry| (entry.path.as_str(), entry))
704        .collect();
705    let status_cache = std::cell::RefCell::new(HashMap::<String, String>::new());
706    let status_of = |path: &str| {
707        entry_by_path
708            .get(path)
709            .map(|entry| status_from_entry(entry))
710            .unwrap_or_else(|| cached_status(&status_cache, path))
711    };
712    retrieve_grounding_from_parts(
713        directory,
714        task,
715        scope,
716        top_k,
717        budget,
718        live_only,
719        keyword,
720        &derived.scope_rows,
721        &derived.relationships,
722        |path| entry_by_path.get(path).map(|entry| (*entry).clone()),
723        status_of,
724    )
725}
726
727/// Grounding over the immutable mmap store. Search uses postings, path lookup
728/// uses the persisted path map, and only the relationship/scope projections
729/// required by grounding are decoded.
730pub fn retrieve_grounding_from_store(
731    directory: &str,
732    task: &str,
733    scope: Option<&str>,
734    top_k: i64,
735    budget: i64,
736    live_only: bool,
737    reader: &crate::index_store::MmapIndexReader,
738) -> Value {
739    let search_started = crate::timing::start();
740    let keyword = crate::read_model::store_search(reader, task, None, &[], false);
741    crate::timing::emit_since(
742        "grounding.search",
743        search_started,
744        &[("matches", keyword.matches.len() as u64)],
745    );
746    let decode_started = crate::timing::start();
747    let scope_rows = if scope.is_some_and(|value| !value.is_empty()) {
748        reader.scope_rows().unwrap_or_default()
749    } else {
750        Vec::new()
751    };
752    let relationships = if live_only {
753        reader.relationships().unwrap_or_default()
754    } else {
755        Vec::new()
756    };
757    crate::timing::emit_since(
758        "grounding.projections",
759        decode_started,
760        &[
761            ("scope_rows", scope_rows.len() as u64),
762            ("relationships", relationships.len() as u64),
763        ],
764    );
765    let status_cache = std::cell::RefCell::new(HashMap::<String, String>::new());
766    let status_of = |path: &str| {
767        reader
768            .docid_for_path(path)
769            .ok()
770            .flatten()
771            .and_then(|docid| reader.entry_status(docid).ok())
772            .unwrap_or_else(|| cached_status(&status_cache, path))
773    };
774    retrieve_grounding_from_parts(
775        directory,
776        task,
777        scope,
778        top_k,
779        budget,
780        live_only,
781        keyword,
782        &scope_rows,
783        &relationships,
784        |path| {
785            reader
786                .docid_for_path(path)
787                .ok()
788                .flatten()
789                .and_then(|docid| reader.identity_entry(docid).ok())
790        },
791        status_of,
792    )
793}
794
795fn cached_status(
796    cache: &std::cell::RefCell<HashMap<String, String>>,
797    path: &str,
798) -> String {
799    if let Some(status) = cache.borrow().get(path) {
800        return status.clone();
801    }
802    let status = if Path::new(path).is_file() {
803        artifact_status(&crate::parse::parse_file(path))
804    } else {
805        String::new()
806    };
807    cache.borrow_mut().insert(path.to_string(), status.clone());
808    status
809}
810
811fn status_from_entry(entry: &IndexEntry) -> String {
812    entry
813        .search_sections
814        .iter()
815        .find(|section| py_casefold(py_strip(&section.heading)) == "status")
816        .and_then(|section| {
817            section
818                .lines
819                .iter()
820                .map(|line| py_strip(line))
821                .find(|line| !line.is_empty())
822        })
823        .unwrap_or("")
824        .to_string()
825}
826
827#[allow(clippy::too_many_arguments)]
828fn retrieve_grounding_from_parts<EntryForPath, StatusOf>(
829    directory: &str,
830    task: &str,
831    scope: Option<&str>,
832    top_k: i64,
833    budget: i64,
834    live_only: bool,
835    keyword: SearchResult,
836    scope_rows: &[ScopeRow],
837    relationships: &[Relationship],
838    entry_for_path: EntryForPath,
839    status_of: StatusOf,
840) -> Value
841where
842    EntryForPath: Fn(&str) -> Option<IndexEntry>,
843    StatusOf: Fn(&str) -> String,
844{
845    let top_k = top_k.max(1);
846    let is_retired = |path: &str| -> bool {
847        let artifact_type = entry_for_path(path)
848            .map(|entry| entry.artifact_type)
849            .unwrap_or_else(|| DECISION_TYPE.to_string());
850        is_retired_status(&artifact_type, &status_of(path))
851    };
852
853    let mut items: Vec<ItemBuilder> = Vec::new();
854    let mut index_of: HashMap<String, usize> = HashMap::new();
855
856    // Scope stratum: declared `## Applies To` coverage binds regardless of
857    // keyword match; the rows are live by construction.
858    let scope = scope.filter(|s| !s.is_empty()); // Python `if scope:` truthiness
859    if let Some(scope_path) = scope {
860        for governing in governing_decisions(scope_rows, directory, scope_path) {
861            add_item(
862                &mut items,
863                &mut index_of,
864                &governing.path,
865                CHANNEL_SCOPE,
866                &governing.id,
867                DECISION_TYPE,
868                if governing.title.is_empty() {
869                    None
870                } else {
871                    Some(&governing.title)
872                },
873                &governing.status,
874                Some(&governing.matching_entry),
875                None,
876                None,
877            );
878        }
879    }
880
881    // Keyword stratum.
882    let by_target = if live_only {
883        successor_map(relationships)
884    } else {
885        HashMap::new()
886    };
887    for m in &keyword.matches {
888        if live_only && is_retired(&m.path) {
889            let mut visited: std::collections::HashSet<String> =
890                std::collections::HashSet::new();
891            visited.insert(m.path.clone());
892            for successor_path in live_successors(&m.path, &by_target, &is_retired, &mut visited)
893            {
894                let Some(successor) = entry_for_path(&successor_path) else {
895                    continue;
896                };
897                add_item(
898                    &mut items,
899                    &mut index_of,
900                    &successor_path,
901                    CHANNEL_SUPERSEDES,
902                    &successor.id,
903                    &successor.artifact_type,
904                    successor.title.as_deref(),
905                    &status_of(&successor_path),
906                    None,
907                    Some(&m.id),
908                    None,
909                );
910            }
911            continue;
912        }
913        add_item(
914            &mut items,
915            &mut index_of,
916            &m.path,
917            CHANNEL_KEYWORD,
918            &m.id,
919            &m.artifact_type,
920            m.title.as_deref(),
921            &status_of(&m.path),
922            None,
923            None,
924            m.evidence.as_ref().map(crate::output::evidence_value),
925        );
926    }
927
928    let selected: Vec<ItemBuilder> = {
929        let keep = (top_k.max(0) as usize).min(items.len());
930        items.truncate(keep);
931        items
932    };
933    // Even excerpt shaping: each item's excerpt is the head of the artifact's
934    // stored text capped at the budget's per-item share.
935    let share = if selected.is_empty() {
936        0
937    } else {
938        budget.div_euclid((top_k.min(selected.len() as i64)).max(1))
939    };
940    let mut shaped: Vec<Value> = Vec::new();
941    for item in selected {
942        let content = read_text_universal(&item.path).unwrap_or_default();
943        let mut obj = Map::new();
944        obj.insert("id".to_string(), json!(item.id));
945        obj.insert("type".to_string(), json!(item.item_type));
946        obj.insert(
947            "title".to_string(),
948            item.title.map(|t| json!(t)).unwrap_or(Value::Null),
949        );
950        obj.insert("status".to_string(), json!(item.status));
951        obj.insert("path".to_string(), json!(item.path));
952        obj.insert("excerpt".to_string(), json!(py_slice_to(&content, share)));
953        obj.insert("provenance".to_string(), Value::Object(item.provenance));
954        shaped.push(Value::Object(obj));
955    }
956
957    let mut payload = Map::new();
958    payload.insert("schema_version".to_string(), json!("1"));
959    payload.insert("task".to_string(), json!(task));
960    if let Some(scope_path) = scope {
961        payload.insert("scope".to_string(), json!(scope_path));
962    }
963    payload.insert("live_only".to_string(), json!(live_only));
964    payload.insert("items".to_string(), Value::Array(shaped));
965    Value::Object(payload)
966}