Skip to main content

rac_engine/
resolve.rs

1//! Resolve & search (`decided resolve`, `decided find`) — a port of
2//! `src/asdecided/services/resolve.py` (+ the index construction in
3//! `src/asdecided/services/index.py`), per PORT-CONTRACT.d/06.
4//!
5//! Landmines reproduced here (contract §15):
6//! - ASCII-only tokenizer (§1) vs full-Unicode casefold/strip in exact
7//!   resolution (§3) and the `--tag` facet (§5.2).
8//! - Corpus statistics are corpus-global (all types, unknowns included) even
9//!   under `--type`/`--tag`; ranks are over the matched set only (§6).
10//! - Duplicate query tokens are NOT deduped: `df` increments per occurrence
11//!   and the per-term score adds per occurrence (§7.1).
12//! - BM25F float operation ORDER is normative (§7): weighted_tf accumulates
13//!   in `id, title, path, heading, body, tags` field order with zero-tf
14//!   fields skipped; score accumulates in query-token order;
15//!   `idf = ln(1 + (n - d + 0.5)/(d + 0.5))` via plain f64 ops (not ln_1p).
16//! - Competition ranks share on EXACT f64 equality (§8).
17//! - Sort key is `(-py_round(fused, 12), path)`; the stored fused value stays
18//!   unrounded; evidence carries `py_round(., 6)` (§9–10).
19
20use std::collections::{HashMap, HashSet};
21
22use crate::identity::{artifact_identifier, artifact_identifiers};
23use crate::markdown::SearchSection;
24use crate::parse::Artifact;
25use crate::pycompat::{first_nonempty_line, py_casefold, py_round, py_strip};
26use crate::relationships::{
27    corpus_items, edge_spec, resolution_index_from_rows, validation_row, CorpusItem,
28};
29use crate::spec::spec_for;
30
31pub const OUTCOME_RESOLVED: &str = "resolved";
32pub const OUTCOME_NOT_FOUND: &str = "not-found";
33pub const OUTCOME_DUPLICATE: &str = "duplicate";
34
35// Match-field tier ladder (ADR-037/038/109): id, title, tags, path, heading,
36// body — lower rank wins.
37const RANK_ID: i64 = 0;
38const RANK_TITLE: i64 = 1;
39const RANK_TAGS: i64 = 2;
40const RANK_PATH: i64 = 3;
41const RANK_HEADING: i64 = 4;
42const RANK_BODY: i64 = 5;
43
44fn rank_name(rank: i64) -> &'static str {
45    match rank {
46        RANK_ID => "id",
47        RANK_TITLE => "title",
48        RANK_TAGS => "tags",
49        RANK_PATH => "path",
50        RANK_HEADING => "heading",
51        _ => "body",
52    }
53}
54
55// BM25F constants (ADR-078).
56const RRF_K: i64 = 60;
57const GRAPH_WEIGHT: f64 = 0.5;
58const BM25_K1: f64 = 1.2;
59const BM25_B: f64 = 0.75;
60
61/// `_FIELD_BOOSTS` in insertion order — `tags` is LAST, not at its tier
62/// position (deliberate: preserves the pre-ADR-109 float summation order).
63const FIELD_BOOSTS: [(&str, f64); 6] = [
64    ("id", 4.0),
65    ("title", 3.0),
66    ("path", 2.0),
67    ("heading", 1.5),
68    ("body", 1.0),
69    ("tags", 2.5),
70];
71
72// ---------------------------------------------------------------------------
73// Tokenization (ADR-037) — ASCII-only splitter + ASCII camel seams
74// ---------------------------------------------------------------------------
75
76/// `tokenize(text)`: split on runs of non-`[0-9A-Za-z]` (every non-ASCII char
77/// is a separator), split each piece at ASCII lowercase→uppercase seams, then
78/// casefold (pure-ASCII pieces: exactly `A-Z -> a-z`).
79pub fn tokenize(text: &str) -> Vec<String> {
80    let mut tokens: Vec<String> = Vec::new();
81    for piece in text.split(|c: char| !c.is_ascii_alphanumeric()) {
82        if piece.is_empty() {
83            continue;
84        }
85        let bytes = piece.as_bytes();
86        let mut start = 0usize;
87        for i in 1..bytes.len() {
88            if bytes[i - 1].is_ascii_lowercase() && bytes[i].is_ascii_uppercase() {
89                tokens.push(piece[start..i].to_ascii_lowercase());
90                start = i;
91            }
92        }
93        tokens.push(piece[start..].to_ascii_lowercase());
94    }
95    tokens
96}
97
98/// `_term_hits_tokens`: term equals or is a prefix of any token.
99fn term_hits_tokens(term: &str, tokens: &[String]) -> bool {
100    tokens.iter().any(|t| t.starts_with(term))
101}
102
103/// `_tf(term, tokens)`: count of tokens the term equals or prefixes.
104fn tf(term: &str, tokens: &[String]) -> i64 {
105    tokens.iter().filter(|t| t.starts_with(term)).count() as i64
106}
107
108// ---------------------------------------------------------------------------
109// Index entries (decided.services.index.IndexEntry)
110// ---------------------------------------------------------------------------
111
112/// One searchable row of the repository index.
113#[derive(Debug, Clone)]
114pub struct IndexEntry {
115    pub id: String,
116    pub artifact_type: String,
117    pub title: Option<String>,
118    pub path: String,
119    /// Canonical ID first, then legacy aliases (case-insensitively deduped).
120    pub aliases: Vec<String>,
121    pub search_sections: Vec<SearchSection>,
122    /// Count of resolved inbound relationship edges (the graph signal).
123    pub inbound_count: i64,
124    /// Frontmatter tags, in frontmatter order.
125    pub tags: Vec<String>,
126}
127
128/// The identity-only projection of an entry (the oracle's `_identity_index`):
129/// `_identity_index` never reads tags/sections/graph, so those stay at their
130/// empty defaults — the resolved artifact matches the oracle's shape exactly
131/// and the discarded clones never happen.
132pub(crate) fn identity_entry_from_item(item: &CorpusItem) -> IndexEntry {
133    let artifact_type = item
134        .spec
135        .map(|s| s.name.clone())
136        .unwrap_or_else(|| "unknown".to_string());
137    IndexEntry {
138        id: artifact_identifier(&item.artifact, item.spec, &item.path),
139        artifact_type,
140        title: item.artifact.product.title.clone(),
141        path: item.path.clone(),
142        aliases: artifact_identifiers(&item.artifact, item.spec, &item.path),
143        search_sections: Vec::new(),
144        inbound_count: 0,
145        tags: Vec::new(),
146    }
147}
148
149pub(crate) fn entry_from_item(item: &CorpusItem, inbound: i64) -> IndexEntry {
150    IndexEntry {
151        search_sections: item.artifact.product.search_sections.clone(),
152        inbound_count: inbound,
153        tags: item
154            .artifact
155            .metadata
156            .as_ref()
157            .map(|m| m.tags.clone())
158            .unwrap_or_default(),
159        ..identity_entry_from_item(item)
160    }
161}
162
163/// `inbound_counts_from_corpus`: `{path -> count of resolved edges pointing
164/// at it}` — resolved, unique, non-self edges only; external edges (ADR-087)
165/// never resolve.
166fn inbound_counts(items: &[CorpusItem]) -> HashMap<String, i64> {
167    let rows: Vec<_> = items
168        .iter()
169        .map(|item| validation_row(&item.path, &item.artifact, item.spec))
170        .collect();
171    let index = resolution_index_from_rows(&rows);
172    let mut counts: HashMap<String, i64> = HashMap::new();
173    for row in &rows {
174        for (section, refs) in &row.edges {
175            let external = edge_spec(section).map(|e| e.external).unwrap_or(false);
176            if external {
177                continue;
178            }
179            for r in refs {
180                let targets = index.get(&py_casefold(r));
181                if targets.len() == 1 && targets[0].0 != row.path {
182                    *counts.entry(targets[0].0.clone()).or_insert(0) += 1;
183                }
184            }
185        }
186    }
187    counts
188}
189
190/// `build_repository_index(directory, recursive).artifacts` — the searchable
191/// index in corpus-walk (sorted-path) order, inbound counts included.
192pub fn build_index(directory: &str, recursive: bool) -> Vec<IndexEntry> {
193    index_from_items(&corpus_items(directory, recursive))
194}
195
196pub fn index_from_items(items: &[CorpusItem]) -> Vec<IndexEntry> {
197    let inbound = inbound_counts(items);
198    items
199        .iter()
200        .map(|item| entry_from_item(item, *inbound.get(&item.path).unwrap_or(&0)))
201        .collect()
202}
203
204// ---------------------------------------------------------------------------
205// Exact resolution (contract §3)
206// ---------------------------------------------------------------------------
207
208/// One resolved artifact / search match (`ResolvedArtifact`).
209#[derive(Debug, Clone)]
210pub struct ResolvedArtifact {
211    pub id: String,
212    pub artifact_type: String,
213    pub title: Option<String>,
214    pub path: String,
215    pub section: Option<String>,
216    pub snippet: Option<String>,
217    pub evidence: Option<Evidence>,
218    pub recency: Option<Recency>,
219    pub tags: Vec<String>,
220}
221
222/// The `--explain` evidence object plus the unrounded score components
223/// (`bm25_raw`/`fused_raw` are not serialized; they exist so conformance
224/// tests can assert exact f64 bit equality against the oracle).
225#[derive(Debug, Clone)]
226pub struct Evidence {
227    pub field: &'static str,
228    /// Distinct casefolded query tokens, in query order.
229    pub terms: Vec<String>,
230    pub tier: i64,
231    /// `py_round(fused, 6)`.
232    pub score: f64,
233    /// `py_round(bm25, 6)`.
234    pub bm25: f64,
235    pub lexical_rank: i64,
236    pub graph_rank: i64,
237    pub inbound: i64,
238    /// The unrounded BM25F score (test-only surface).
239    pub bm25_raw: f64,
240    /// The unrounded fused RRF score (test-only surface).
241    pub fused_raw: f64,
242}
243
244/// The git-derived recency join (`Staleness.to_dict()` shape).
245#[derive(Debug, Clone)]
246pub struct Recency {
247    pub last_committed: Option<String>,
248    pub age_days: Option<i64>,
249    pub stale: Option<bool>,
250}
251
252/// Outcome of one exact-ID lookup (`ResolutionResult`).
253#[derive(Debug, Clone)]
254pub struct ResolutionResult {
255    /// The query as given (unstripped).
256    pub artifact_id: String,
257    pub outcome: &'static str,
258    pub artifact: Option<ResolvedArtifact>,
259    pub duplicate_paths: Vec<String>,
260}
261
262pub(crate) fn resolved_from_entry(entry: &IndexEntry) -> ResolvedArtifact {
263    ResolvedArtifact {
264        id: entry.id.clone(),
265        artifact_type: entry.artifact_type.clone(),
266        title: entry.title.clone(),
267        path: entry.path.clone(),
268        section: None,
269        snippet: None,
270        evidence: None,
271        recency: None,
272        tags: entry.tags.clone(),
273    }
274}
275
276/// `resolve_in_index(entries, artifact_id)`: full-Unicode strip + casefold on
277/// the query, casefolded exact equality against every alias.
278pub fn resolve_in_index(entries: &[IndexEntry], artifact_id: &str) -> ResolutionResult {
279    let wanted = py_casefold(py_strip(artifact_id));
280    let matches: Vec<&IndexEntry> = entries
281        .iter()
282        .filter(|e| e.aliases.iter().any(|a| py_casefold(a) == wanted))
283        .collect();
284    if matches.is_empty() {
285        return ResolutionResult {
286            artifact_id: artifact_id.to_string(),
287            outcome: OUTCOME_NOT_FOUND,
288            artifact: None,
289            duplicate_paths: Vec::new(),
290        };
291    }
292    if matches.len() > 1 {
293        let mut paths: Vec<String> = matches.iter().map(|e| e.path.clone()).collect();
294        paths.sort(); // Python str sort = code-point order = UTF-8 byte order
295        return ResolutionResult {
296            artifact_id: artifact_id.to_string(),
297            outcome: OUTCOME_DUPLICATE,
298            artifact: None,
299            duplicate_paths: paths,
300        };
301    }
302    ResolutionResult {
303        artifact_id: artifact_id.to_string(),
304        outcome: OUTCOME_RESOLVED,
305        artifact: Some(resolved_from_entry(matches[0])),
306        duplicate_paths: Vec::new(),
307    }
308}
309
310/// `resolve_artifact(directory, artifact_id, recursive)`. The oracle's
311/// identity-only walk (`_identity_index`) leaves sections/graph/tags at their
312/// empty defaults; resolve output reads only id/type/title/path, so those
313/// fields never surface (resolve JSON never gains a "tags" key).
314pub fn resolve_artifact(directory: &str, artifact_id: &str, recursive: bool) -> ResolutionResult {
315    let items = corpus_items(directory, recursive);
316    let entries: Vec<IndexEntry> = items.iter().map(identity_entry_from_item).collect();
317    resolve_in_index(&entries, artifact_id)
318}
319
320// ---------------------------------------------------------------------------
321// Tokenised entries (contract §4)
322// ---------------------------------------------------------------------------
323
324/// Flat per-field token vectors, one per scorable field.
325#[derive(Debug, Clone, Default)]
326pub struct FieldTokens {
327    pub id: Vec<String>,
328    pub title: Vec<String>,
329    pub tags: Vec<String>,
330    pub path: Vec<String>,
331    pub heading: Vec<String>,
332    pub body: Vec<String>,
333}
334
335impl FieldTokens {
336    pub(crate) fn get(&self, name: &str) -> &Vec<String> {
337        match name {
338            "id" => &self.id,
339            "title" => &self.title,
340            "tags" => &self.tags,
341            "path" => &self.path,
342            "heading" => &self.heading,
343            _ => &self.body,
344        }
345    }
346}
347
348struct SectionTokens {
349    heading: String,
350    heading_tokens: Vec<String>,
351    lines: Vec<(String, Vec<String>)>,
352}
353
354pub(crate) struct EntryTokens {
355    pub(crate) fields: FieldTokens,
356    sections: Vec<SectionTokens>,
357}
358
359pub(crate) fn tokenize_entry(entry: &IndexEntry) -> EntryTokens {
360    let mut sections: Vec<SectionTokens> = Vec::new();
361    let mut heading_tokens: Vec<String> = Vec::new();
362    let mut body_tokens: Vec<String> = Vec::new();
363    for sec in &entry.search_sections {
364        let sec_heading_tokens = tokenize(&sec.heading);
365        heading_tokens.extend(sec_heading_tokens.iter().cloned());
366        let mut sec_lines: Vec<(String, Vec<String>)> = Vec::new();
367        for line in &sec.lines {
368            let line_tokens = tokenize(line);
369            body_tokens.extend(line_tokens.iter().cloned());
370            sec_lines.push((line.clone(), line_tokens));
371        }
372        sections.push(SectionTokens {
373            heading: sec.heading.clone(),
374            heading_tokens: sec_heading_tokens,
375            lines: sec_lines,
376        });
377    }
378    let mut id_tokens: Vec<String> = Vec::new();
379    for alias in &entry.aliases {
380        id_tokens.extend(tokenize(alias));
381    }
382    let mut tag_tokens: Vec<String> = Vec::new();
383    for tag in &entry.tags {
384        tag_tokens.extend(tokenize(tag));
385    }
386    EntryTokens {
387        fields: FieldTokens {
388            id: id_tokens,
389            title: tokenize(entry.title.as_deref().unwrap_or("")),
390            tags: tag_tokens,
391            path: tokenize(&entry.path),
392            heading: heading_tokens,
393            body: body_tokens,
394        },
395        sections,
396    }
397}
398
399/// The six flat per-field token vectors of one entry — the projection the
400/// derived read-model persists (`field_tokens_for_entries`, INDEX-PLAN B2).
401pub(crate) fn field_tokens_of(entry: &IndexEntry) -> FieldTokens {
402    tokenize_entry(entry).fields
403}
404
405// ---------------------------------------------------------------------------
406// Tier matching (contract §4)
407// ---------------------------------------------------------------------------
408
409#[derive(Clone)]
410pub(crate) struct TierMatch {
411    rank: i64,
412    section: Option<String>,
413    snippet: Option<String>,
414    /// Distinct matched terms, in query-token order.
415    terms: Vec<String>,
416}
417
418fn match_fields(fields: &FieldTokens, terms: &[String]) -> Option<(i64, Vec<String>)> {
419    let mut matched_terms: HashSet<&str> = HashSet::new();
420    let mut best_rank: Option<i64> = None;
421    for (rank, field) in [
422        (RANK_ID, "id"),
423        (RANK_TITLE, "title"),
424        (RANK_TAGS, "tags"),
425        (RANK_PATH, "path"),
426        (RANK_HEADING, "heading"),
427        (RANK_BODY, "body"),
428    ] {
429        let tokens = fields.get(field);
430        let mut any = false;
431        for term in terms {
432            if term_hits_tokens(term, tokens) {
433                matched_terms.insert(term.as_str());
434                any = true;
435            }
436        }
437        if any && best_rank.is_none() {
438            best_rank = Some(rank);
439        }
440    }
441
442    // AND semantics: every distinct term must have matched somewhere.
443    let distinct: HashSet<&str> = terms.iter().map(|t| t.as_str()).collect();
444    if !distinct.is_subset(&matched_terms) {
445        return None;
446    }
447    let best_rank = best_rank?;
448
449    // Matched terms in query order, deduped (dict.fromkeys semantics).
450    let mut seen: HashSet<&str> = HashSet::new();
451    let mut ordered: Vec<String> = Vec::new();
452    for term in terms {
453        if seen.insert(term.as_str()) && matched_terms.contains(term.as_str()) {
454            ordered.push(term.clone());
455        }
456    }
457    Some((best_rank, ordered))
458}
459
460fn any_term_hits(terms: &[String], tokens: &[String]) -> bool {
461    terms.iter().any(|term| term_hits_tokens(term, tokens))
462}
463
464pub(crate) fn match_entry(entry_tokens: &EntryTokens, terms: &[String]) -> Option<TierMatch> {
465    let (best_rank, ordered) = match_fields(&entry_tokens.fields, terms)?;
466
467    // Only the winning tier's snippet is surfaced; metadata wins carry none.
468    let snippet = match best_rank {
469        RANK_HEADING => entry_tokens
470            .sections
471            .iter()
472            .find(|section| any_term_hits(terms, &section.heading_tokens))
473            .map(|section| (section.heading.clone(), section.heading.clone())),
474        RANK_BODY => entry_tokens.sections.iter().find_map(|section| {
475            section
476                .lines
477                .iter()
478                .find(|(_, tokens)| any_term_hits(terms, tokens))
479                .map(|(line, _)| (section.heading.clone(), line.clone()))
480        }),
481        _ => None,
482    };
483    let (section, snippet) = match snippet {
484        Some((section, line)) => (Some(section), Some(line)),
485        None => (None, None),
486    };
487    Some(TierMatch {
488        rank: best_rank,
489        section,
490        snippet,
491        terms: ordered,
492    })
493}
494
495/// Match a store row using its persisted flat tokens. Raw section text is
496/// tokenized only until the winning heading/body snippet is found; metadata
497/// winners do not rebuild section tokens at all.
498pub(crate) fn match_entry_with_fields(
499    entry: &IndexEntry,
500    fields: &FieldTokens,
501    terms: &[String],
502) -> Option<TierMatch> {
503    let (best_rank, ordered) = match_fields(fields, terms)?;
504    let snippet = match best_rank {
505        RANK_HEADING => entry.search_sections.iter().find_map(|section| {
506            let tokens = tokenize(&section.heading);
507            any_term_hits(terms, &tokens)
508                .then(|| (section.heading.clone(), section.heading.clone()))
509        }),
510        RANK_BODY => entry.search_sections.iter().find_map(|section| {
511            section.lines.iter().find_map(|line| {
512                let tokens = tokenize(line);
513                any_term_hits(terms, &tokens).then(|| (section.heading.clone(), line.clone()))
514            })
515        }),
516        _ => None,
517    };
518    let (section, snippet) = match snippet {
519        Some((section, line)) => (Some(section), Some(line)),
520        None => (None, None),
521    };
522    Some(TierMatch {
523        rank: best_rank,
524        section,
525        snippet,
526        terms: ordered,
527    })
528}
529
530// ---------------------------------------------------------------------------
531// Corpus statistics + BM25F (contract §6–7)
532// ---------------------------------------------------------------------------
533
534/// Corpus-global BM25 statistics (all entries, unknowns included).
535pub struct CorpusStats {
536    pub n: i64,
537    /// Per-term document frequency; duplicate query terms double-count.
538    pub df: HashMap<String, i64>,
539    /// Mean field length in `FIELD_BOOSTS` order.
540    pub avglen: [f64; 6],
541}
542
543fn corpus_stats(field_tokens: &[FieldTokens], terms: &[String]) -> CorpusStats {
544    let n = field_tokens.len() as i64;
545    let mut length_sums = [0i64; 6];
546    let mut df: HashMap<String, i64> = HashMap::new();
547    for term in terms {
548        df.entry(term.clone()).or_insert(0);
549    }
550    for fields in field_tokens {
551        for (i, (name, _)) in FIELD_BOOSTS.iter().enumerate() {
552            length_sums[i] += fields.get(name).len() as i64;
553        }
554        // Duplicates iterate: a term appearing twice increments its df twice.
555        for term in terms {
556            if FIELD_BOOSTS
557                .iter()
558                .any(|(name, _)| tf(term, fields.get(name)) != 0)
559            {
560                *df.get_mut(term.as_str()).expect("df pre-seeded") += 1;
561            }
562        }
563    }
564    let mut avglen = [0.0f64; 6];
565    for (i, sum) in length_sums.iter().enumerate() {
566        avglen[i] = if n != 0 { *sum as f64 / n as f64 } else { 0.0 };
567    }
568    CorpusStats { n, df, avglen }
569}
570
571/// Corpus-global statistics for one query over `entries` — the conformance
572/// vector surface (`gen_vectors_resolve.py` pins `n`/`df`/`avglen`).
573pub fn stats_for(entries: &[IndexEntry], query: &str) -> CorpusStats {
574    let terms = tokenize(query);
575    let field_tokens: Vec<FieldTokens> = entries
576        .iter()
577        .map(|e| tokenize_entry(e).fields)
578        .collect();
579    corpus_stats(&field_tokens, &terms)
580}
581
582/// `_bm25f` — the EXACT f64 operation sequence (contract §7).
583fn bm25f(fields: &FieldTokens, terms: &[String], stats: &CorpusStats) -> f64 {
584    let mut score = 0.0f64;
585    for term in terms {
586        // QUERY-TOKEN ORDER, DUPLICATES INCLUDED
587        let d = *stats.df.get(term.as_str()).unwrap_or(&0);
588        if d == 0 {
589            continue;
590        }
591        // arg = 1 + (n - d + 0.5)/(d + 0.5) — plain add, then ln (not ln_1p).
592        let num = (stats.n - d) as f64 + 0.5;
593        let den = d as f64 + 0.5;
594        let idf = (1.0 + num / den).ln();
595        let mut weighted_tf = 0.0f64;
596        for (i, (name, boost)) in FIELD_BOOSTS.iter().enumerate() {
597            let tokens = fields.get(name);
598            let tfv = tf(term, tokens);
599            if tfv == 0 {
600                continue; // zero-tf fields are SKIPPED (no +0.0 term)
601            }
602            let length = tokens.len() as i64;
603            let mean = stats.avglen[i];
604            let denom = if mean > 0.0 {
605                1.0 - BM25_B + BM25_B * (length as f64 / mean)
606            } else {
607                1.0
608            };
609            weighted_tf += boost * (tfv as f64 / denom);
610        }
611        if weighted_tf > 0.0 {
612            score += idf * (weighted_tf / (BM25_K1 + weighted_tf));
613        }
614    }
615    score
616}
617
618/// `_competition_ranks`: 1-based ranks aligned with `scores`; ties (EXACT
619/// f64 equality) share a rank, ordered by `(-score, path)`.
620fn competition_ranks(scores: &[f64], paths: &[&str]) -> Vec<i64> {
621    let mut ordered: Vec<usize> = (0..scores.len()).collect();
622    ordered.sort_by(|&a, &b| {
623        scores[b]
624            .partial_cmp(&scores[a])
625            .expect("finite score")
626            .then_with(|| paths[a].cmp(paths[b]))
627    });
628    let mut ranks = vec![0; scores.len()];
629    let mut previous: Option<f64> = None;
630    let mut rank = 0i64;
631    for (position, &index) in ordered.iter().enumerate() {
632        let position = position as i64 + 1;
633        if Some(scores[index]) != previous {
634            rank = position;
635            previous = Some(scores[index]);
636        }
637        ranks[index] = rank;
638    }
639    ranks
640}
641
642// ---------------------------------------------------------------------------
643// Search (contract §4–10)
644// ---------------------------------------------------------------------------
645
646/// Outcome of one repository search (`SearchResult`).
647#[derive(Debug, Clone)]
648pub struct SearchResult {
649    pub query: String,
650    /// The `--type` value; `"decision"` under `--decisions`; else None.
651    pub artifact_type: Option<String>,
652    pub matches: Vec<ResolvedArtifact>,
653}
654
655/// `_entry_has_tags`: exact whole-tag comparison, full Unicode casefold.
656pub(crate) fn entry_has_tags(entry: &IndexEntry, wanted: &[String]) -> bool {
657    let have: HashSet<String> = entry.tags.iter().map(|t| py_casefold(t)).collect();
658    wanted.iter().all(|w| have.contains(w))
659}
660
661/// `search_index(entries, query, artifact_type, tags)` — matching, corpus
662/// stats, BM25F + RRF ranking, and the `(-round(fused,12), path)` sort.
663pub fn search_index(
664    entries: &[IndexEntry],
665    query: &str,
666    artifact_type: Option<&str>,
667    tags: &[String],
668) -> SearchResult {
669    search_index_filtered(entries, query, artifact_type, tags, false)
670}
671
672/// `entry_is_retired(entry)` — the `live_only` facet (ADR-113): re-read the
673/// entry's `## Status` from its file and test it against the type's
674/// `retired_status` set (`is_retired_status`). Unreadable/unknown ⇒ live.
675pub fn entry_is_retired(entry: &IndexEntry) -> bool {
676    let status = artifact_status(&crate::parse::parse_file(&entry.path));
677    is_retired_status(&entry.artifact_type, &status)
678}
679
680/// `agent_rules.is_retired_status(artifact_type, status)` (ADR-113):
681/// spec-driven retirement for every typed artifact. An unknown type retires
682/// nothing; an empty status is never retired.
683pub fn is_retired_status(artifact_type: &str, status: &str) -> bool {
684    let Some(spec) = spec_for(artifact_type) else {
685        return false;
686    };
687    let wanted = py_casefold(status);
688    spec.retired_status.iter().any(|s| py_casefold(s) == wanted)
689}
690
691/// `search_index(..., live_only=...)` (ADR-113, additive): with `live_only`,
692/// retired artifacts of every type are dropped from the matched set BEFORE
693/// scoring, so competition ranks are computed among the live survivors. With
694/// `live_only=false` the result is byte-identical to `search_index`.
695pub fn search_index_filtered(
696    entries: &[IndexEntry],
697    query: &str,
698    artifact_type: Option<&str>,
699    tags: &[String],
700    live_only: bool,
701) -> SearchResult {
702    let terms = tokenize(query);
703    let tag_filter: Vec<String> = tags.iter().map(|t| py_casefold(t)).collect();
704    let mut matched: Vec<(usize, TierMatch)> = Vec::new();
705    let mut tokenized: Vec<Option<EntryTokens>> = Vec::with_capacity(entries.len());
706    tokenized.resize_with(entries.len(), || None);
707    if !terms.is_empty() {
708        for (i, entry) in entries.iter().enumerate() {
709            if let Some(t) = artifact_type {
710                if entry.artifact_type != t {
711                    continue;
712                }
713            }
714            if !tag_filter.is_empty() && !entry_has_tags(entry, &tag_filter) {
715                continue;
716            }
717            let entry_tokens = tokenize_entry(entry);
718            let m = match_entry(&entry_tokens, &terms);
719            tokenized[i] = Some(entry_tokens);
720            if let Some(m) = m {
721                matched.push((i, m));
722            }
723        }
724    }
725    // The live-only facet filters the matched set before scoring (ADR-113), so
726    // competition ranks are computed among the live survivors — only matched
727    // files are re-read, never the whole corpus.
728    if live_only && !matched.is_empty() {
729        matched.retain(|(i, _)| !entry_is_retired(&entries[*i]));
730    }
731    if matched.is_empty() {
732        return SearchResult {
733            query: query.to_string(),
734            artifact_type: artifact_type.map(str::to_string),
735            matches: Vec::new(),
736        };
737    }
738
739    // Corpus-wide statistics over EVERY entry (type/tag-excluded included).
740    let field_tokens: Vec<FieldTokens> = entries
741        .iter()
742        .enumerate()
743        .map(|(i, entry)| match tokenized[i].take() {
744            Some(t) => t.fields,
745            None => tokenize_entry(entry).fields,
746        })
747        .collect();
748    let stats = corpus_stats(&field_tokens, &terms);
749
750    let scored: Vec<(&IndexEntry, &FieldTokens, TierMatch)> = matched
751        .into_iter()
752        .map(|(i, m)| (&entries[i], &field_tokens[i], m))
753        .collect();
754    rank_and_build(query, artifact_type, scored, &terms, &stats)
755}
756
757/// The shared scoring/ranking/build tail of the tiered search — one code
758/// path for the fresh walk and the store-served read-model (ADR-104), so
759/// warm and cold emit identical bytes by construction. `matched` rows are
760/// in entry (walk/docid) order; `stats` carries the corpus-global n/df/
761/// avglen however the caller derived them.
762pub(crate) fn rank_and_build(
763    query: &str,
764    artifact_type: Option<&str>,
765    matched: Vec<(&IndexEntry, &FieldTokens, TierMatch)>,
766    terms: &[String],
767    stats: &CorpusStats,
768) -> SearchResult {
769    // Score the matched set only.
770    let bm25_started = crate::timing::start();
771    let bm25_scores: Vec<f64> = matched
772        .iter()
773        .map(|(_, fields, _)| bm25f(fields, terms, stats))
774        .collect();
775    crate::timing::emit_since(
776        "search.bm25f",
777        bm25_started,
778        &[("matched", matched.len() as u64)],
779    );
780    let fusion_started = crate::timing::start();
781    let inbound_scores: Vec<f64> = matched
782        .iter()
783        .map(|(entry, _, _)| entry.inbound_count as f64)
784        .collect();
785    let paths: Vec<&str> = matched.iter().map(|(entry, _, _)| entry.path.as_str()).collect();
786    let lexical_rank = competition_ranks(&bm25_scores, &paths);
787    let graph_rank = competition_ranks(&inbound_scores, &paths);
788    let fused: Vec<f64> = bm25_scores
789        .iter()
790        .enumerate()
791        .map(|(index, _)| {
792            1.0 / ((RRF_K + lexical_rank[index]) as f64)
793                + GRAPH_WEIGHT / ((RRF_K + graph_rank[index]) as f64)
794        })
795        .collect();
796    crate::timing::emit_since(
797        "search.rank_fusion",
798        fusion_started,
799        &[("matched", matched.len() as u64)],
800    );
801
802    // Fused score descending (rounded to 12 places inside the key only),
803    // ties broken by path: total and byte-stable.
804    let sort_started = crate::timing::start();
805    let fused_sort_keys: Vec<f64> = fused.iter().map(|score| py_round(*score, 12)).collect();
806    let mut order: Vec<usize> = (0..matched.len()).collect();
807    order.sort_by(|&a, &b| {
808        fused_sort_keys[b]
809            .partial_cmp(&fused_sort_keys[a])
810            .expect("finite fused")
811            .then_with(|| paths[a].cmp(paths[b]))
812    });
813    crate::timing::emit_since(
814        "search.final_sort",
815        sort_started,
816        &[("matched", matched.len() as u64)],
817    );
818
819    let projection_started = crate::timing::start();
820    let matches: Vec<ResolvedArtifact> = order
821        .into_iter()
822        .map(|index| {
823            let (entry, _, m) = &matched[index];
824            let fused_raw = fused[index];
825            let bm25_raw = bm25_scores[index];
826            ResolvedArtifact {
827                id: entry.id.clone(),
828                artifact_type: entry.artifact_type.clone(),
829                title: entry.title.clone(),
830                path: entry.path.clone(),
831                section: m.section.clone(),
832                snippet: m.snippet.clone(),
833                evidence: Some(Evidence {
834                    field: rank_name(m.rank),
835                    terms: m.terms.clone(),
836                    tier: m.rank,
837                    score: py_round(fused_raw, 6),
838                    bm25: py_round(bm25_raw, 6),
839                    lexical_rank: lexical_rank[index],
840                    graph_rank: graph_rank[index],
841                    inbound: entry.inbound_count,
842                    bm25_raw,
843                    fused_raw,
844                }),
845                recency: None,
846                tags: entry.tags.clone(),
847            }
848        })
849        .collect();
850    crate::timing::emit_since(
851        "search.response_projection",
852        projection_started,
853        &[("matches", matches.len() as u64)],
854    );
855
856    SearchResult {
857        query: query.to_string(),
858        artifact_type: artifact_type.map(str::to_string),
859        matches,
860    }
861}
862
863/// `find_artifacts(directory, query, artifact_type, recursive, tags, live_only)`.
864pub fn find_artifacts(
865    directory: &str,
866    query: &str,
867    artifact_type: Option<&str>,
868    recursive: bool,
869    tags: &[String],
870    live_only: bool,
871) -> SearchResult {
872    let entries = build_index(directory, recursive);
873    search_index_filtered(&entries, query, artifact_type, tags, live_only)
874}
875
876// ---------------------------------------------------------------------------
877// Live decision query (`--decisions`, ADR-067)
878// ---------------------------------------------------------------------------
879
880/// `agent_rules.artifact_status`: first non-empty stripped line of `## Status`.
881pub fn artifact_status(artifact: &Artifact) -> String {
882    artifact
883        .section("status")
884        .map(first_nonempty_line)
885        .unwrap_or("")
886        .to_string()
887}
888
889/// `agent_rules.is_live_decision`: Accepted and not retired.
890pub(crate) fn is_live_decision(artifact: &Artifact) -> bool {
891    let status = py_casefold(&artifact_status(artifact));
892    if status != "accepted" {
893        return false;
894    }
895    let retired: Vec<String> = spec_for("decision")
896        .map(|s| s.retired_status.iter().map(|r| py_casefold(r)).collect())
897        .unwrap_or_default();
898    !retired.contains(&status)
899}
900
901/// `find_decisions(directory, topic, recursive)`: the type-restricted tiered
902/// search, post-filtered to live decisions (ranks keep their gaps — evidence
903/// is computed over all matched decisions including non-live ones).
904pub fn find_decisions(directory: &str, topic: &str, recursive: bool) -> SearchResult {
905    let items = corpus_items(directory, recursive);
906    let live: HashSet<String> = items
907        .iter()
908        .filter(|item| {
909            item.spec.map(|s| s.name.as_str()) == Some("decision")
910                && is_live_decision(&item.artifact)
911        })
912        .map(|item| item.path.clone())
913        .collect();
914    let entries = index_from_items(&items);
915    let mut result = search_index(&entries, topic, Some("decision"), &[]);
916    result.matches.retain(|m| live.contains(&m.path));
917    result
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923
924    fn toks(s: &str) -> Vec<String> {
925        tokenize(s)
926    }
927
928    #[test]
929    fn tokenize_contract_examples() {
930        assert_eq!(toks("soft-delete"), vec!["soft", "delete"]);
931        assert_eq!(toks("camelCase"), vec!["camel", "case"]);
932        assert_eq!(toks("HTTPServer"), vec!["httpserver"]);
933        assert_eq!(
934            toks("MiXeD-Case_fooBAR"),
935            vec!["mi", "xe", "d", "case", "foo", "bar"]
936        );
937        assert_eq!(toks("v0.22.0"), vec!["v0", "22", "0"]);
938        assert_eq!(toks("ADR-037"), vec!["adr", "037"]);
939        assert_eq!(toks("foo_barBaz2Qux"), vec!["foo", "bar", "baz2qux"]);
940        assert_eq!(toks("caf\u{e9}"), vec!["caf"]);
941        assert_eq!(toks("e\u{301}clair"), vec!["e", "clair"]);
942        assert_eq!(toks("\u{130}stanbul"), vec!["stanbul"]);
943        assert_eq!(toks("Stra\u{df}e"), vec!["stra", "e"]);
944        assert_eq!(toks("..."), Vec::<String>::new());
945        assert_eq!(toks(""), Vec::<String>::new());
946    }
947
948    #[test]
949    fn prefix_matching_is_one_directional() {
950        let tokens = vec!["searching".to_string()];
951        assert!(term_hits_tokens("sear", &tokens));
952        assert!(term_hits_tokens("searching", &tokens));
953        assert!(!term_hits_tokens("searchingx", &tokens));
954        assert_eq!(tf("sear", &tokens), 1);
955    }
956
957    #[test]
958    fn competition_ranks_share_on_exact_equality() {
959        let scores = vec![2.0, 2.0, 1.0];
960        let paths = vec!["b", "a", "c"];
961        let ranks = competition_ranks(&scores, &paths);
962        assert_eq!(ranks, vec![1, 1, 3]);
963    }
964
965    #[test]
966    fn persisted_field_matching_preserves_tiers_and_snippets() {
967        let entry = IndexEntry {
968            id: "RAC-EXAMPLE1234".to_string(),
969            artifact_type: "requirement".to_string(),
970            title: Some("Search latency".to_string()),
971            path: "requirements/search-latency.md".to_string(),
972            aliases: vec!["RAC-EXAMPLE1234".to_string(), "legacy-search".to_string()],
973            search_sections: vec![
974                SearchSection {
975                    heading: "Acceptance Criteria".to_string(),
976                    lines: vec!["Warm lookup stays below budget".to_string()],
977                },
978                SearchSection {
979                    heading: "Risks".to_string(),
980                    lines: vec!["Corpus growth may affect sorting".to_string()],
981                },
982            ],
983            inbound_count: 2,
984            tags: vec!["performance".to_string()],
985        };
986        let tokenized = tokenize_entry(&entry);
987        for query in [
988            "example1234",
989            "search performance",
990            "acceptance",
991            "warm budget",
992            "growth sorting",
993            "missing",
994            "search search",
995        ] {
996            let terms = tokenize(query);
997            let fresh = match_entry(&tokenized, &terms);
998            let stored = match_entry_with_fields(&entry, &tokenized.fields, &terms);
999            assert_eq!(fresh.is_some(), stored.is_some(), "query {query:?}");
1000            if let (Some(fresh), Some(stored)) = (fresh, stored) {
1001                assert_eq!(fresh.rank, stored.rank, "query {query:?}: rank");
1002                assert_eq!(fresh.section, stored.section, "query {query:?}: section");
1003                assert_eq!(fresh.snippet, stored.snippet, "query {query:?}: snippet");
1004                assert_eq!(fresh.terms, stored.terms, "query {query:?}: terms");
1005            }
1006        }
1007    }
1008}