Skip to main content

infotheory/
search.rs

1use crate::api::{InfotheoryCtx, empirical_cross_entropy_bytes, empirical_entropy_bytes};
2use crate::backends::rosaplus::RosaPlus;
3use crate::error::{InfotheoryError, InfotheoryResult};
4#[cfg(feature = "backend-rwkv")]
5use crate::spec::MethodBackendFamily;
6use crate::spec::RateBackendTraceStrategy;
7use rayon::prelude::*;
8use std::collections::hash_map::DefaultHasher;
9use std::fs;
10use std::hash::{Hash, Hasher};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
14/// One scored retrieval unit returned by code search.
15pub struct Snippet {
16    /// Source file containing the match/candidate.
17    pub path: PathBuf,
18    /// 1-based inclusive start line for snippet display.
19    pub start_line: usize,
20    /// 1-based inclusive end line for snippet display.
21    pub end_line: usize,
22    /// Raw candidate bytes used for entropy/rerank scoring.
23    pub content: Vec<u8>,
24    /// Final ranking score (larger is better).
25    pub score: f64,
26}
27
28fn stage0_prefilter(
29    query_bytes: &[u8],
30    mut candidates: Vec<Snippet>,
31    opts: &SearchOptions,
32    debug: bool,
33) -> InfotheoryResult<Vec<Snippet>> {
34    let n = candidates.len();
35    if n == 0 {
36        return Ok(candidates);
37    }
38
39    let frac = opts.stage0_keep_frac.clamp(0.0, 1.0);
40    if frac >= 1.0 {
41        return Ok(candidates);
42    }
43
44    // Option A: Unigram (i.i.d.) likelihood-gain proxy.
45    // score0(x) = H0(Q) - H0(Q|X)
46    // where H0(Q|X) is the empirical cross-entropy of Q under X's unigram model.
47    let h0_q = empirical_entropy_bytes(query_bytes);
48    candidates.par_iter_mut().try_for_each(|s| {
49        let h0_q_x = empirical_cross_entropy_bytes(query_bytes, &s.content);
50        s.score = h0_q - h0_q_x;
51        Ok::<(), InfotheoryError>(())
52    })?;
53
54    let mut keep = ((n as f64) * frac).ceil() as usize;
55    keep = keep.max(opts.top_k).min(n);
56    if keep < n {
57        let nth = keep.saturating_sub(1);
58        candidates.select_nth_unstable_by(nth, |a, b| {
59            b.score
60                .partial_cmp(&a.score)
61                .unwrap_or(std::cmp::Ordering::Equal)
62        });
63        candidates.truncate(keep);
64    }
65
66    if debug {
67        println!(
68            "Stage-0 prefilter kept {}/{} candidates (frac={:.4})",
69            candidates.len(),
70            n,
71            frac
72        );
73    }
74
75    Ok(candidates)
76}
77
78#[derive(Debug, Clone, Copy, Eq, PartialEq)]
79/// Candidate unit granularity for stage-0/1 collection.
80pub enum SearchGranularity {
81    /// Split files into hashed line windows/snippets.
82    Snippet,
83    /// Treat each file as a single candidate.
84    File,
85}
86
87#[derive(Debug, Clone, Copy, Eq, PartialEq)]
88/// Stage-2 prior handling strategy for KMI reranking.
89pub enum Stage2PriorMode {
90    /// Use the (full or summarized) universal prior as a prefix for compression metrics.
91    Use,
92    /// Do NOT use the universal prior in Stage 2 (pure NCD/KMI rerank on Stage-1-filtered set).
93    Disable,
94    /// Summarize the universal prior via an inner prior-less search over the prior corpus.
95    Summarize,
96}
97
98#[derive(Clone)]
99/// Tunables for the three-stage information-theoretic search pipeline.
100pub struct SearchOptions {
101    /// Candidate granularity at collection time.
102    pub granularity: SearchGranularity,
103    /// Universal prior corpus path (file or directory). If set:
104    /// - Stage 1 always uses it.
105    /// - Stage 2 uses it by default (unless Stage2PriorMode::Disable).
106    pub universal_prior: Option<String>,
107    /// Whether/how Stage-2 reranking uses universal prior context.
108    pub stage2_prior_mode: Stage2PriorMode,
109    /// Number of final results to keep.
110    pub top_k: usize,
111    /// Fraction of candidates retained by the unigram prefilter.
112    pub stage0_keep_frac: f64,
113    /// Fully configured information-theory context/backend bundle.
114    ///
115    /// Algorithm-specific configuration (such as ROSA's `max_order`) lives
116    /// inside the rate backend's variant and is read from it when needed.
117    pub ctx: InfotheoryCtx,
118}
119
120/// Default rate backend name used by CLI search when no backend flags are supplied.
121pub const DEFAULT_SEARCH_RATE_BACKEND_NAME: &str = "rosaplus";
122/// Default compression backend name used by CLI search when no backend flags are supplied.
123#[cfg(feature = "backend-zpaq")]
124pub const DEFAULT_SEARCH_COMPRESSION_BACKEND_NAME: &str = "zpaq";
125/// Default compression backend name used by CLI search when no backend flags are supplied.
126#[cfg(not(feature = "backend-zpaq"))]
127pub const DEFAULT_SEARCH_COMPRESSION_BACKEND_NAME: &str = "rate-ac";
128
129fn default_search_ctx() -> InfotheoryResult<InfotheoryCtx> {
130    InfotheoryCtx::try_default()
131}
132
133impl SearchOptions {
134    /// Build the default search configuration for the current feature slice.
135    pub fn try_default() -> InfotheoryResult<Self> {
136        Ok(Self {
137            granularity: SearchGranularity::Snippet,
138            universal_prior: None,
139            stage2_prior_mode: Stage2PriorMode::Use,
140            top_k: 50,
141            stage0_keep_frac: 0.2,
142            ctx: default_search_ctx()?,
143        })
144    }
145}
146
147/// Run search with default options and print top shell extraction commands.
148pub fn run_search(query: &str, target_path: &str) -> InfotheoryResult<()> {
149    let opts = SearchOptions::try_default()?;
150    run_search_with_options(query, target_path, &opts)
151}
152
153/// Run search with explicit options and print top shell extraction commands.
154pub fn run_search_with_options(
155    query: &str,
156    target_path: &str,
157    opts: &SearchOptions,
158) -> InfotheoryResult<()> {
159    let debug = std::env::var("DEBUG_SEARCH").is_ok();
160    let results = search_with_options(query, target_path, opts)?;
161    for (i, snippet) in results.iter().take(5).enumerate() {
162        if debug {
163            println!(
164                "Rank {}: Score={:.6}, Path={}",
165                i + 1,
166                snippet.score,
167                snippet.path.display()
168            );
169        }
170        println!(
171            "sed -n '{},{}p' {}",
172            snippet.start_line,
173            snippet.end_line,
174            snippet.path.display()
175        );
176    }
177    Ok(())
178}
179
180/// Run the full 3-stage search pipeline and return ranked results.
181///
182/// The returned `Vec<Snippet>` is sorted by descending score, truncated
183/// to `opts.top_k` entries.  Each snippet carries its file path, line
184/// range, content bytes, and final KMI-reranked score.
185pub fn search_with_options(
186    query: &str,
187    target_path: &str,
188    opts: &SearchOptions,
189) -> InfotheoryResult<Vec<Snippet>> {
190    let debug = std::env::var("DEBUG_SEARCH").is_ok();
191    let query_bytes = resolve_query_bytes(query);
192    if query_bytes.is_empty() {
193        return Err(InfotheoryError::runtime("search query is empty"));
194    }
195
196    if debug {
197        println!(
198            "Scanning target: {} (granularity={:?}, prior={}, stage2_prior_mode={:?})",
199            target_path,
200            opts.granularity,
201            opts.universal_prior.as_deref().unwrap_or("<none>"),
202            opts.stage2_prior_mode
203        );
204    }
205
206    let candidates = collect_candidates(target_path, opts.granularity);
207    if candidates.is_empty() {
208        return Err(InfotheoryError::runtime(format!(
209            "no accessible files found in target '{target_path}'"
210        )));
211    }
212
213    let candidates = stage0_prefilter(query_bytes.as_slice(), candidates, opts, debug)?;
214    if candidates.is_empty() {
215        return Err(InfotheoryError::runtime(
216            "no candidates remain after the stage-0 prefilter",
217        ));
218    }
219    if debug {
220        println!("Found {} candidates. Filtering...", candidates.len());
221    }
222
223    // Stage 1: Filter
224    let mut scored_candidates = if let Some(prior_path) = opts.universal_prior.as_deref() {
225        stage1_filter_with_universal_prior(&query_bytes, prior_path, candidates, opts)?
226    } else {
227        stage1_filter_no_prior(&query_bytes, candidates, opts)?
228    };
229
230    let top_k_size = opts.top_k.min(scored_candidates.len());
231    if top_k_size < scored_candidates.len() {
232        let nth = top_k_size.saturating_sub(1);
233        scored_candidates.select_nth_unstable_by(nth, |a, b| {
234            b.score
235                .partial_cmp(&a.score)
236                .unwrap_or(std::cmp::Ordering::Equal)
237        });
238        scored_candidates.truncate(top_k_size);
239    }
240
241    scored_candidates.sort_by(|a, b| {
242        b.score
243            .partial_cmp(&a.score)
244            .unwrap_or(std::cmp::Ordering::Equal)
245    });
246
247    let top_candidates = &mut scored_candidates[..top_k_size];
248    if debug {
249        println!(
250            "Reranking top {} candidates with Kolmogorov Mutual Information...",
251            top_k_size
252        );
253    }
254
255    // Stage 2: Rerank
256    stage2_rerank_kmi(&query_bytes, top_candidates, opts)?;
257    top_candidates.sort_by(|a, b| {
258        b.score
259            .partial_cmp(&a.score)
260            .unwrap_or(std::cmp::Ordering::Equal)
261    });
262
263    Ok(scored_candidates)
264}
265
266fn resolve_query_bytes(query: &str) -> Vec<u8> {
267    let p = Path::new(query);
268    if p.exists() && fs::metadata(p).map(|m| m.is_file()).unwrap_or(false) {
269        fs::read(p).unwrap_or_else(|_| query.as_bytes().to_vec())
270    } else {
271        query.as_bytes().to_vec()
272    }
273}
274
275fn stage1_filter_no_prior(
276    query_bytes: &[u8],
277    candidates: Vec<Snippet>,
278    opts: &SearchOptions,
279) -> InfotheoryResult<Vec<Snippet>> {
280    let h_q = opts
281        .ctx
282        .try_entropy_rate_bytes(query_bytes)
283        .map_err(|err| InfotheoryError::runtime(format!("stage-1 search entropy failed: {err}")))?;
284
285    let scored: InfotheoryResult<Vec<Snippet>> = candidates
286        .into_par_iter()
287        .map(|mut snippet| {
288            let h_q_x = opts
289                .ctx
290                .try_cross_entropy_rate_bytes(query_bytes, &snippet.content)
291                .map_err(|err| {
292                    InfotheoryError::runtime(format!("stage-1 search cross entropy failed: {err}"))
293                })?;
294            snippet.score = h_q - h_q_x;
295            Ok(snippet)
296        })
297        .collect();
298
299    // Keep equivalence with old behavior by not clamping.
300    scored
301}
302
303fn stage1_filter_with_universal_prior(
304    query_bytes: &[u8],
305    prior_path: &str,
306    candidates: Vec<Snippet>,
307    opts: &SearchOptions,
308) -> InfotheoryResult<Vec<Snippet>> {
309    #[cfg(feature = "backend-rwkv")]
310    if let Some((mut base, prior_snapshot)) = rwkv_prior_snapshot(opts, prior_path) {
311        let h_u_q = {
312            base.restore_runtime(&prior_snapshot);
313            base.cross_entropy_from_current(query_bytes)
314                .map_err(|err| {
315                    InfotheoryError::runtime(format!("stage-1 rwkv prior scoring failed: {err}"))
316                })?
317        };
318        return candidates
319            .into_par_iter()
320            .map_init(
321                || base.clone(),
322                |m: &mut crate::rwkvzip::Compressor, mut snippet| {
323                    m.restore_runtime(&prior_snapshot);
324                    m.absorb_chain(&[snippet.content.as_slice()])
325                        .map_err(|err| {
326                            InfotheoryError::runtime(format!(
327                                "stage-1 rwkv candidate absorption failed: {err}"
328                            ))
329                        })?;
330                    let h_ux_q = m.cross_entropy_from_current(query_bytes).map_err(|err| {
331                        InfotheoryError::runtime(format!(
332                            "stage-1 rwkv candidate scoring failed: {err}"
333                        ))
334                    })?;
335                    snippet.score = h_u_q - h_ux_q;
336                    Ok(snippet)
337                },
338            )
339            .collect();
340    }
341
342    if opts.ctx.rate_backend.capabilities().trace_strategy != RateBackendTraceStrategy::Rosa {
343        let prior_prefix = corpus_bytes(prior_path, SearchGranularity::File);
344        let h_u_q = opts
345            .ctx
346            .try_cross_entropy_conditional_chain(&[prior_prefix.as_slice()], query_bytes)
347            .map_err(|err| {
348                InfotheoryError::runtime(format!(
349                    "stage-1 conditional-chain prior scoring failed: {err}"
350                ))
351            })?;
352        return candidates
353            .into_par_iter()
354            .map(|mut snippet| {
355                let h_ux_q = opts
356                    .ctx
357                    .try_cross_entropy_conditional_chain(
358                        &[prior_prefix.as_slice(), snippet.content.as_slice()],
359                        query_bytes,
360                    )
361                    .map_err(|err| {
362                        InfotheoryError::runtime(format!(
363                            "stage-1 conditional-chain candidate scoring failed: {err}"
364                        ))
365                    })?;
366                snippet.score = h_u_q - h_ux_q;
367                Ok(snippet)
368            })
369            .collect();
370    }
371
372    // PERFORMANCE NOTE:
373    // Training the prior using snippet-level windows would duplicate overlapping content
374    // and explode runtime. We *always* train/load the prior at file granularity.
375    let mut base = load_or_train_prior_model(prior_path, opts);
376    // For true conditional updates we require the fixed 256-byte alphabet LM.
377    // This ensures symbol indices remain stable across incremental updates.
378    base.ensure_lm_built_no_finalize_endpos();
379    // Reduce the cost of cloning `base` per worker.
380    base.shrink_aux_buffers();
381
382    // Precompute query codepoints once (cross_entropy() would allocate this per call).
383    let query_cps: Vec<u32> = query_bytes.iter().map(|&b| b as u32).collect();
384    let h_u_q = base.cross_entropy_cps(&query_cps);
385
386    // True conditional update:
387    // score(x) = H_U(q) - H_{U+x}(q)
388    // by applying a reversible candidate update to the *full* prior model.
389    //
390    // MEMORY NOTE:
391    // `map_init(|| base.clone(), ...)` clones the model once per Rayon worker.
392    // For large priors this can blow up RSS. We cap worker count based on an estimate
393    // of model bytes and best-effort available memory (Linux).
394    let model_bytes = base.estimated_size_bytes().max(1);
395    let threads = memory_aware_threads(model_bytes);
396    let pool = rayon::ThreadPoolBuilder::new()
397        .num_threads(threads)
398        .build()
399        .map_err(|err| InfotheoryError::runtime(format!("failed to build rayon pool: {err}")))?;
400
401    pool.install(|| {
402        candidates
403            .into_par_iter()
404            .map_init(
405                || base.clone(),
406                |m, mut snippet| {
407                    let mut tx = m.begin_tx();
408                    m.train_example_tx(&mut tx, &snippet.content);
409                    let h_ux_q = m.cross_entropy_cps(&query_cps);
410                    m.rollback_tx(tx);
411                    snippet.score = h_u_q - h_ux_q;
412                    Ok(snippet)
413                },
414            )
415            .collect()
416    })
417}
418
419#[cfg(feature = "backend-rwkv")]
420fn rwkv_prior_snapshot(
421    opts: &SearchOptions,
422    prior_path: &str,
423) -> Option<(crate::rwkvzip::Compressor, crate::rwkvzip::RuntimeSnapshot)> {
424    if opts.ctx.rate_backend.capabilities().method_family != Some(MethodBackendFamily::Rwkv7) {
425        return None;
426    }
427    let method = opts.ctx.rate_backend.method_string()?;
428    let mut compressor = crate::rwkvzip::Compressor::new_from_method(method).ok()?;
429
430    let prior_prefix = corpus_bytes(prior_path, SearchGranularity::File);
431    compressor.reset_and_prime();
432    let _ = compressor.absorb_chain(&[prior_prefix.as_slice()]);
433    let snapshot = compressor.snapshot_runtime();
434    Some((compressor, snapshot))
435}
436
437fn memory_aware_threads(model_bytes: usize) -> usize {
438    let hw = num_cpus::get().max(1);
439    let avail = linux_mem_available_bytes().unwrap_or(0);
440    if avail == 0 {
441        return hw;
442    }
443
444    // Heuristic: allow up to 25% of available memory for (worker clones + overhead).
445    let budget = (avail / 4).max(model_bytes as u64);
446    let max_by_mem = (budget / (model_bytes as u64)).max(1) as usize;
447    hw.min(max_by_mem).max(1)
448}
449
450fn linux_mem_available_bytes() -> Option<u64> {
451    // Linux-only best-effort. If parsing fails, fall back to unconstrained.
452    let s = std::fs::read_to_string("/proc/meminfo").ok()?;
453    for line in s.lines() {
454        if let Some(rest) = line.strip_prefix("MemAvailable:") {
455            let parts: Vec<&str> = rest.split_whitespace().collect();
456            if parts.is_empty() {
457                return None;
458            }
459            let kb: u64 = parts[0].parse().ok()?;
460            return Some(kb.saturating_mul(1024));
461        }
462    }
463    None
464}
465
466fn stage2_rerank_kmi(
467    query_bytes: &[u8],
468    top_candidates: &mut [Snippet],
469    opts: &SearchOptions,
470) -> InfotheoryResult<()> {
471    let prior_prefix: Option<Vec<u8>> =
472        match (opts.universal_prior.as_deref(), opts.stage2_prior_mode) {
473            (None, _) => None,
474            (Some(_), Stage2PriorMode::Disable) => None,
475            (Some(prior_path), Stage2PriorMode::Use) => {
476                Some(corpus_bytes(prior_path, SearchGranularity::File))
477            }
478            (Some(prior_path), Stage2PriorMode::Summarize) => {
479                Some(summarize_prior_for_query(query_bytes, prior_path, opts)?)
480            }
481        };
482
483    let cq = if let Some(prefix) = prior_prefix.as_deref() {
484        opts.ctx
485            .try_compress_size_chain(&[prefix, query_bytes])
486            .map_err(|err| {
487                InfotheoryError::runtime(format!("stage-2 query compression failed: {err}"))
488            })?
489    } else {
490        opts.ctx
491            .try_compress_size_chain(&[query_bytes])
492            .map_err(|err| {
493                InfotheoryError::runtime(format!("stage-2 query compression failed: {err}"))
494            })?
495    };
496
497    top_candidates.par_iter_mut().try_for_each(|snippet| {
498        let cx = if let Some(prefix) = prior_prefix.as_deref() {
499            opts.ctx
500                .try_compress_size_chain(&[prefix, snippet.content.as_slice()])
501                .map_err(|err| {
502                    InfotheoryError::runtime(format!("stage-2 candidate compression failed: {err}"))
503                })?
504        } else {
505            opts.ctx
506                .try_compress_size_chain(&[snippet.content.as_slice()])
507                .map_err(|err| {
508                    InfotheoryError::runtime(format!("stage-2 candidate compression failed: {err}"))
509                })?
510        };
511
512        let c1 = if let Some(prefix) = prior_prefix.as_deref() {
513            opts.ctx
514                .try_compress_size_chain(&[prefix, snippet.content.as_slice(), query_bytes])
515                .map_err(|err| {
516                    InfotheoryError::runtime(format!("stage-2 joint compression failed: {err}"))
517                })?
518        } else {
519            opts.ctx
520                .try_compress_size_chain(&[snippet.content.as_slice(), query_bytes])
521                .map_err(|err| {
522                    InfotheoryError::runtime(format!("stage-2 joint compression failed: {err}"))
523                })?
524        };
525
526        let c2 = if let Some(prefix) = prior_prefix.as_deref() {
527            opts.ctx
528                .try_compress_size_chain(&[prefix, query_bytes, snippet.content.as_slice()])
529                .map_err(|err| {
530                    InfotheoryError::runtime(format!("stage-2 joint compression failed: {err}"))
531                })?
532        } else {
533            opts.ctx
534                .try_compress_size_chain(&[query_bytes, snippet.content.as_slice()])
535                .map_err(|err| {
536                    InfotheoryError::runtime(format!("stage-2 joint compression failed: {err}"))
537                })?
538        };
539
540        let c_joint = c1.min(c2);
541        snippet.score = if c_joint == u64::MAX {
542            0.0
543        } else {
544            (cq as f64 + cx as f64 - c_joint as f64).max(0.0)
545        };
546        Ok::<(), InfotheoryError>(())
547    })?;
548    Ok(())
549}
550
551fn summarize_prior_for_query(
552    query_bytes: &[u8],
553    prior_path: &str,
554    opts: &SearchOptions,
555) -> InfotheoryResult<Vec<u8>> {
556    // Prior-less search inside the prior corpus itself.
557    // We approximate K(q|x) via conditional compression: min(C(xq),C(qx)) - C(x), and select the MIN.
558    let candidates = collect_candidates(prior_path, opts.granularity);
559    if candidates.is_empty() {
560        return Ok(Vec::new());
561    }
562
563    let cq = opts
564        .ctx
565        .try_compress_size_chain(&[query_bytes])
566        .map_err(|err| {
567            InfotheoryError::runtime(format!(
568                "prior summarization query compression failed: {err}"
569            ))
570        })?;
571
572    let mut best: Option<(f64, Vec<u8>)> = None;
573    for c in candidates {
574        let cx = opts
575            .ctx
576            .try_compress_size_chain(&[c.content.as_slice()])
577            .map_err(|err| {
578                InfotheoryError::runtime(format!(
579                    "prior summarization candidate compression failed: {err}"
580                ))
581            })?;
582
583        let cxq = opts
584            .ctx
585            .try_compress_size_chain(&[c.content.as_slice(), query_bytes])
586            .map_err(|err| {
587                InfotheoryError::runtime(format!(
588                    "prior summarization joint compression failed: {err}"
589                ))
590            })?;
591        let cqx = opts
592            .ctx
593            .try_compress_size_chain(&[query_bytes, c.content.as_slice()])
594            .map_err(|err| {
595                InfotheoryError::runtime(format!(
596                    "prior summarization joint compression failed: {err}"
597                ))
598            })?;
599        let c_joint = cxq.min(cqx);
600        if c_joint == u64::MAX {
601            continue;
602        }
603        // Conditional complexity proxy.
604        let k_q_given_x = (c_joint as f64 - cx as f64).max(0.0);
605        // Tie-breaker: if equal, prefer smaller candidate.
606        let candidate_key = (k_q_given_x, cx as f64, cq as f64);
607        let is_better = match &best {
608            None => true,
609            Some((best_k, best_bytes)) => {
610                let best_cx = opts.ctx.try_compress_size(best_bytes).map_err(|err| {
611                    InfotheoryError::runtime(format!(
612                        "prior summarization tie-break compression failed: {err}"
613                    ))
614                })? as f64;
615                (candidate_key.0, candidate_key.1) < (*best_k, best_cx)
616            }
617        };
618        if is_better {
619            best = Some((k_q_given_x, c.content));
620        }
621    }
622
623    Ok(best.map(|(_, b)| b).unwrap_or_default())
624}
625
626fn train_rosa_on_corpus(m: &mut RosaPlus, corpus_path: &str, granularity: SearchGranularity) {
627    // Train incrementally on each candidate to avoid giant concatenations.
628    for c in collect_candidates(corpus_path, granularity) {
629        if !c.content.is_empty() {
630            m.train_example(&c.content);
631        }
632    }
633}
634
635fn prior_cache_path(prior_path: &str, max_order: i64) -> Option<PathBuf> {
636    let home = std::env::var("XDG_CACHE_HOME")
637        .ok()
638        .or_else(|| std::env::var("HOME").ok().map(|h| format!("{}/.cache", h)));
639    let cache_root = match home {
640        Some(h) => PathBuf::from(h).join("infotheory").join("rosa_prior"),
641        None => return None,
642    };
643
644    let mut hasher = DefaultHasher::new();
645    // Cache format/version (bump when training or serialization semantics change).
646    (5u32).hash(&mut hasher);
647    prior_path.hash(&mut hasher);
648    max_order.hash(&mut hasher);
649    // file-granularity is baked into the cache key (we always use it for prior training)
650    ("file" as &str).hash(&mut hasher);
651    let key = hasher.finish();
652    Some(cache_root.join(format!("prior_{:016x}.rosa", key)))
653}
654
655fn load_or_train_prior_model(prior_path: &str, opts: &SearchOptions) -> RosaPlus {
656    // This path is only reached when the backend's trace strategy is Rosa,
657    // so the plan is guaranteed to be RosaPlus.
658    let crate::spec::core::RateBackendPlan::RosaPlus { max_order } = opts.ctx.rate_backend.plan()
659    else {
660        unreachable!("load_or_train_prior_model called with non-ROSA backend")
661    };
662    let max_order: i64 = *max_order;
663
664    // Load cached prior model if present.
665    if let Some(cache_path) = prior_cache_path(prior_path, max_order) {
666        if let Some(parent) = cache_path.parent() {
667            let _ = fs::create_dir_all(parent);
668        }
669        if cache_path.exists()
670            && let Ok(mut m) = RosaPlus::load(cache_path.to_string_lossy().as_ref())
671        {
672            // Ensure fixed 256-byte alphabet LM for incremental conditional updates.
673            if m.lm_alpha_n() != 256 {
674                m.build_lm_full_bytes_no_finalize_endpos();
675                let _ = m.save(cache_path.to_string_lossy().as_ref());
676            }
677            return m;
678        }
679
680        // Train + save.
681        let mut m = RosaPlus::new(max_order, false, 0, 42);
682        train_rosa_on_corpus(&mut m, prior_path, SearchGranularity::File);
683        // Build a fixed-byte alphabet LM once so the saved model is the full state.
684        m.build_lm_full_bytes_no_finalize_endpos();
685        let _ = m.save(cache_path.to_string_lossy().as_ref());
686        return m;
687    }
688
689    // Fallback: no cache location available.
690    let mut m = RosaPlus::new(max_order, false, 0, 42);
691    train_rosa_on_corpus(&mut m, prior_path, SearchGranularity::File);
692    m
693}
694
695fn corpus_bytes(corpus_path: &str, granularity: SearchGranularity) -> Vec<u8> {
696    // Compression prior prefix requires a concrete byte buffer.
697    // We join candidates with a simple delimiter to preserve boundaries.
698    let mut out = Vec::new();
699    for c in collect_candidates(corpus_path, granularity) {
700        if c.content.is_empty() {
701            continue;
702        }
703        out.extend_from_slice(&c.content);
704        out.extend_from_slice(b"\n\n");
705    }
706    out
707}
708
709fn collect_candidates(target: &str, granularity: SearchGranularity) -> Vec<Snippet> {
710    let mut snippets = Vec::new();
711    let path = Path::new(target);
712
713    if path.exists() {
714        if path.is_file() {
715            snippets.extend(file_to_candidates(path, granularity));
716        } else if path.is_dir() {
717            visit_dirs(path, &mut snippets, granularity);
718        }
719    }
720
721    snippets
722}
723
724fn visit_dirs(dir: &Path, snippets: &mut Vec<Snippet>, granularity: SearchGranularity) {
725    if let Ok(entries) = fs::read_dir(dir) {
726        for entry in entries.flatten() {
727            let path = entry.path();
728            if path.is_dir() {
729                if let Some(name_str) = path.file_name().and_then(|n| n.to_str())
730                    && !name_str.starts_with('.')
731                {
732                    visit_dirs(&path, snippets, granularity);
733                }
734            } else {
735                snippets.extend(file_to_candidates(&path, granularity));
736            }
737        }
738    }
739}
740
741fn file_to_candidates(path: &Path, granularity: SearchGranularity) -> Vec<Snippet> {
742    let mut snippets = Vec::new();
743
744    // Only process text files
745    if let Some(ext) = path.extension() {
746        let ext_str = ext.to_string_lossy();
747        if matches!(
748            ext_str.as_ref(),
749            "o" | "a" | "so" | "dll" | "exe" | "bin" | "png" | "jpg" | "zip" | "gz"
750        ) {
751            return snippets;
752        }
753    }
754
755    match granularity {
756        SearchGranularity::File => {
757            if let Ok(bytes) = fs::read(path)
758                && !bytes.is_empty()
759            {
760                // Best-effort line count for `sed` output.
761                let lines = bytes.iter().filter(|&&b| b == b'\n').count() + 1;
762                snippets.push(Snippet {
763                    path: path.to_path_buf(),
764                    start_line: 1,
765                    end_line: lines.max(1),
766                    content: bytes,
767                    score: 0.0,
768                });
769            }
770        }
771        SearchGranularity::Snippet => {
772            if let Ok(bytes) = fs::read(path) {
773                if bytes.is_empty() {
774                    return snippets;
775                }
776
777                let window = 50usize;
778                let stride = 20usize;
779
780                let mut line_starts: Vec<usize> = Vec::new();
781                line_starts.push(0);
782                for (i, &b) in bytes.iter().enumerate() {
783                    if b == b'\n' {
784                        let next = i + 1;
785                        if next < bytes.len() {
786                            line_starts.push(next);
787                        }
788                    }
789                }
790
791                if line_starts.is_empty() {
792                    return snippets;
793                }
794
795                let mut i = 0usize;
796                while i < line_starts.len() {
797                    let end = (i + window).min(line_starts.len());
798                    let start_b = line_starts[i];
799                    let end_b = if end >= line_starts.len() {
800                        bytes.len()
801                    } else {
802                        line_starts[end]
803                    };
804
805                    if end_b > start_b {
806                        let content = bytes[start_b..end_b].to_vec();
807                        if content.len() > 50 {
808                            snippets.push(Snippet {
809                                path: path.to_path_buf(),
810                                start_line: i + 1,
811                                end_line: end,
812                                content,
813                                score: 0.0,
814                            });
815                        }
816                    }
817
818                    if end == line_starts.len() {
819                        break;
820                    }
821                    i += stride;
822                }
823            }
824        }
825    }
826    snippets
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use crate::api::{CompressionBackend, InfotheoryCtx, RateBackend};
833    #[cfg(feature = "backend-zpaq")]
834    use crate::error::InfotheoryError;
835    use std::time::{SystemTime, UNIX_EPOCH};
836
837    fn temp_path(prefix: &str) -> PathBuf {
838        let nanos = SystemTime::now()
839            .duration_since(UNIX_EPOCH)
840            .expect("clock before epoch")
841            .as_nanos();
842        std::env::temp_dir().join(format!("infotheory-search-{prefix}-{nanos}"))
843    }
844
845    fn write_text(path: &Path, text: &str) {
846        fs::write(path, text.as_bytes()).expect("write temp text fixture");
847    }
848
849    fn ctw_search_ctx() -> InfotheoryCtx {
850        InfotheoryCtx::from_specs(
851            RateBackend::Ctw { depth: 10 },
852            CompressionBackend::Rate {
853                rate_backend: RateBackend::Ctw { depth: 10 },
854                coder: crate::coders::CoderType::AC,
855                framing: crate::compression::FramingMode::Raw,
856            },
857        )
858        .expect("ctw search context should compile")
859    }
860
861    #[test]
862    fn resolve_query_bytes_prefers_file_contents() {
863        let path = temp_path("query");
864        fs::write(&path, b"query-from-file").expect("write query file");
865        let got = resolve_query_bytes(path.to_string_lossy().as_ref());
866        assert_eq!(got, b"query-from-file");
867        let _ = fs::remove_file(path);
868    }
869
870    #[test]
871    fn file_to_candidates_skips_binary_extensions() {
872        let path = temp_path("binary").with_extension("png");
873        fs::write(&path, b"not-actually-image").expect("write pseudo-binary");
874        let out = file_to_candidates(&path, SearchGranularity::File);
875        assert!(out.is_empty(), "binary extension should be skipped");
876        let _ = fs::remove_file(path);
877    }
878
879    #[test]
880    fn file_to_candidates_generates_snippets() {
881        let path = temp_path("snippet").with_extension("txt");
882        let mut text = String::new();
883        for i in 0..120 {
884            text.push_str(&format!("line-{i:03}\n"));
885        }
886        fs::write(&path, text.as_bytes()).expect("write snippet file");
887        let out = file_to_candidates(&path, SearchGranularity::Snippet);
888        assert!(!out.is_empty(), "expected snippet candidates");
889        assert!(out.iter().all(|s| s.end_line >= s.start_line));
890        let _ = fs::remove_file(path);
891    }
892
893    #[test]
894    fn collect_candidates_skips_hidden_directories() {
895        let root = temp_path("tree");
896        let hidden = root.join(".hidden");
897        let visible = root.join("visible");
898        fs::create_dir_all(&hidden).expect("create hidden dir");
899        fs::create_dir_all(&visible).expect("create visible dir");
900        fs::write(hidden.join("secret.txt"), b"hidden").expect("write hidden file");
901        fs::write(visible.join("public.txt"), b"visible\ntext\n").expect("write visible file");
902
903        let out = collect_candidates(root.to_string_lossy().as_ref(), SearchGranularity::File);
904        assert_eq!(out.len(), 1, "only visible file should be collected");
905        assert!(
906            out[0].path.to_string_lossy().contains("public.txt"),
907            "unexpected collected file path: {}",
908            out[0].path.display()
909        );
910
911        let _ = fs::remove_dir_all(root);
912    }
913
914    #[test]
915    fn stage0_prefilter_respects_topk_floor() {
916        let mut candidates = Vec::new();
917        for i in 0..10 {
918            candidates.push(Snippet {
919                path: PathBuf::from(format!("f{i}.txt")),
920                start_line: 1,
921                end_line: 1,
922                content: format!("candidate-{i}").into_bytes(),
923                score: 0.0,
924            });
925        }
926        let opts = SearchOptions {
927            top_k: 4,
928            stage0_keep_frac: 0.1,
929            ..SearchOptions::try_default().expect("search defaults")
930        };
931        let kept = stage0_prefilter(b"candidate", candidates, &opts, false)
932            .expect("stage0 prefilter should succeed");
933        assert!(
934            kept.len() >= 4,
935            "stage0 must keep at least top_k candidates, got {}",
936            kept.len()
937        );
938    }
939
940    #[test]
941    fn stage0_prefilter_full_fraction_is_noop() {
942        let candidates = vec![
943            Snippet {
944                path: PathBuf::from("a.txt"),
945                start_line: 1,
946                end_line: 1,
947                content: b"alpha beta".to_vec(),
948                score: 0.0,
949            },
950            Snippet {
951                path: PathBuf::from("b.txt"),
952                start_line: 2,
953                end_line: 3,
954                content: b"gamma delta".to_vec(),
955                score: 0.0,
956            },
957        ];
958        let opts = SearchOptions {
959            stage0_keep_frac: 1.0,
960            top_k: 1,
961            ..SearchOptions::try_default().expect("search defaults")
962        };
963        let kept = stage0_prefilter(b"alpha", candidates.clone(), &opts, false)
964            .expect("prefilter should succeed");
965        assert_eq!(kept.len(), candidates.len());
966        assert_eq!(kept[0].path, candidates[0].path);
967        assert_eq!(kept[1].path, candidates[1].path);
968    }
969
970    #[test]
971    fn search_with_options_returns_error_for_empty_query() {
972        let path = temp_path("search-empty").with_extension("txt");
973        fs::write(&path, b"content").expect("write search target");
974        let opts = SearchOptions::try_default().expect("search defaults");
975        let err = search_with_options("", path.to_string_lossy().as_ref(), &opts)
976            .expect_err("empty query should return an error");
977        assert!(err.to_string().contains("query is empty"));
978        let _ = fs::remove_file(path);
979    }
980
981    #[test]
982    fn search_with_options_returns_error_for_missing_target() {
983        let opts = SearchOptions::try_default().expect("search defaults");
984        let err = search_with_options(
985            "needle",
986            "/definitely/missing/infotheory-search-target",
987            &opts,
988        )
989        .expect_err("missing target should return an error");
990        assert!(err.to_string().contains("no accessible files found"));
991    }
992
993    #[test]
994    fn stage1_filter_no_prior_prefers_exact_match_candidate() {
995        let opts = SearchOptions {
996            granularity: SearchGranularity::File,
997            top_k: 2,
998            stage0_keep_frac: 1.0,
999            ctx: ctw_search_ctx(),
1000            ..SearchOptions::try_default().expect("search defaults")
1001        };
1002        let candidates = vec![
1003            Snippet {
1004                path: PathBuf::from("noise.txt"),
1005                start_line: 1,
1006                end_line: 1,
1007                content: b"background entropy without the query phrase".to_vec(),
1008                score: 0.0,
1009            },
1010            Snippet {
1011                path: PathBuf::from("match.txt"),
1012                start_line: 1,
1013                end_line: 1,
1014                content: b"needle exact stage one phrase repeated needle exact stage one phrase"
1015                    .to_vec(),
1016                score: 0.0,
1017            },
1018        ];
1019        let scored = stage1_filter_no_prior(b"needle exact stage one phrase", candidates, &opts)
1020            .expect("stage1 without prior should succeed");
1021        assert_eq!(scored.len(), 2);
1022        assert!(
1023            scored[1].score > scored[0].score,
1024            "exact match candidate should score above unrelated content"
1025        );
1026    }
1027
1028    #[test]
1029    fn stage1_filter_with_universal_prior_prefers_prior_consistent_candidate() {
1030        let prior_root = temp_path("stage1-prior");
1031        fs::create_dir_all(&prior_root).expect("create prior dir");
1032        write_text(
1033            &prior_root.join("prior.txt"),
1034            "predictive coding exact phrase context\npredictive coding exact phrase context\n",
1035        );
1036
1037        let opts = SearchOptions {
1038            granularity: SearchGranularity::File,
1039            universal_prior: Some(prior_root.to_string_lossy().to_string()),
1040            stage2_prior_mode: Stage2PriorMode::Use,
1041            top_k: 2,
1042            stage0_keep_frac: 1.0,
1043            ctx: ctw_search_ctx(),
1044        };
1045        let candidates = vec![
1046            Snippet {
1047                path: PathBuf::from("noise.txt"),
1048                start_line: 1,
1049                end_line: 1,
1050                content: b"background corpus without predictive coding context".to_vec(),
1051                score: 0.0,
1052            },
1053            Snippet {
1054                path: PathBuf::from("match.txt"),
1055                start_line: 1,
1056                end_line: 1,
1057                content: b"predictive coding exact phrase continuation".to_vec(),
1058                score: 0.0,
1059            },
1060        ];
1061
1062        let scored = stage1_filter_with_universal_prior(
1063            b"predictive coding exact phrase",
1064            prior_root.to_string_lossy().as_ref(),
1065            candidates,
1066            &opts,
1067        )
1068        .expect("stage1 with prior should succeed");
1069        assert_eq!(scored.len(), 2);
1070        assert!(
1071            scored[1].score > scored[0].score,
1072            "prior-consistent candidate should outrank unrelated content"
1073        );
1074
1075        let _ = fs::remove_dir_all(prior_root);
1076    }
1077
1078    #[test]
1079    fn search_with_options_snippet_granularity_prefers_matching_window() {
1080        let path = temp_path("snippet-search").with_extension("txt");
1081        let mut text = String::new();
1082        for i in 0..80 {
1083            if i == 41 {
1084                text.push_str("needle exact snippet phrase lives here\n");
1085            } else {
1086                text.push_str(&format!("background line {i}\n"));
1087            }
1088        }
1089        fs::write(&path, text.as_bytes()).expect("write snippet corpus");
1090
1091        let opts = SearchOptions {
1092            granularity: SearchGranularity::Snippet,
1093            top_k: 1,
1094            stage0_keep_frac: 1.0,
1095            ctx: ctw_search_ctx(),
1096            ..SearchOptions::try_default().expect("search defaults")
1097        };
1098        let results = search_with_options(
1099            "needle exact snippet phrase",
1100            path.to_string_lossy().as_ref(),
1101            &opts,
1102        )
1103        .expect("snippet search should succeed");
1104        assert_eq!(results.len(), 1);
1105        assert_eq!(results[0].path, path);
1106        assert!(results[0].start_line <= 42 && results[0].end_line >= 42);
1107
1108        let _ = fs::remove_file(path);
1109    }
1110
1111    #[test]
1112    fn search_with_options_file_granularity_truncates_and_sorts_results() {
1113        let root = temp_path("file-search");
1114        fs::create_dir_all(&root).expect("create target dir");
1115        let best = root.join("best.txt");
1116        let second = root.join("second.txt");
1117        let noise = root.join("noise.txt");
1118        write_text(
1119            &best,
1120            "needle exact file phrase\nneedle exact file phrase\nneedle exact file phrase\n",
1121        );
1122        write_text(&second, "needle exact file\npartial overlap only\n");
1123        write_text(&noise, "completely unrelated material\n");
1124
1125        let opts = SearchOptions {
1126            granularity: SearchGranularity::File,
1127            top_k: 2,
1128            stage0_keep_frac: 1.0,
1129            ctx: ctw_search_ctx(),
1130            ..SearchOptions::try_default().expect("search defaults")
1131        };
1132        let results = search_with_options(
1133            "needle exact file phrase",
1134            root.to_string_lossy().as_ref(),
1135            &opts,
1136        )
1137        .expect("file search should succeed");
1138        assert_eq!(results.len(), 2);
1139        assert_eq!(results[0].path, best);
1140        assert!(results[0].score >= results[1].score);
1141        assert_ne!(
1142            results[1].path, noise,
1143            "noise candidate should be truncated away"
1144        );
1145
1146        let _ = fs::remove_dir_all(root);
1147    }
1148
1149    #[cfg(feature = "backend-zpaq")]
1150    #[test]
1151    fn search_with_options_surfaces_runtime_backend_errors() {
1152        let path = temp_path("search-runtime").with_extension("txt");
1153        fs::write(&path, b"haystack").expect("write search target");
1154
1155        let ctx = InfotheoryCtx::from_specs(
1156            RateBackend::RosaPlus { max_order: -1 },
1157            CompressionBackend::zpaq("definitely-invalid-zpaq-method"),
1158        )
1159        .expect("context should compile");
1160
1161        let opts = SearchOptions {
1162            top_k: 1,
1163            ctx,
1164            ..SearchOptions::try_default().expect("search defaults")
1165        };
1166        let err = search_with_options("needle", path.to_string_lossy().as_ref(), &opts)
1167            .expect_err("invalid compression method should surface as a search error");
1168        assert!(matches!(
1169            err,
1170            InfotheoryError::Runtime(_)
1171                | InfotheoryError::Unsupported(_)
1172                | InfotheoryError::InvalidBackendConfig(_)
1173        ));
1174
1175        let _ = fs::remove_file(path);
1176    }
1177
1178    #[test]
1179    fn search_defaults_match_feature_slice_defaults() {
1180        let opts = SearchOptions::try_default().expect("search defaults");
1181        match opts.ctx.compression_backend.canonical_spec() {
1182            #[cfg(feature = "backend-zpaq")]
1183            crate::api::CompressionBackend::Zpaq { method, .. } => assert_eq!(method.value(), "5"),
1184            #[cfg(feature = "backend-zpaq")]
1185            crate::api::CompressionBackend::Rate { .. } => {
1186                panic!("zpaq-enabled default search context should use zpaq compression");
1187            }
1188            #[cfg(not(feature = "backend-zpaq"))]
1189            crate::api::CompressionBackend::Rate {
1190                rate_backend,
1191                coder,
1192                framing,
1193            } => {
1194                assert!(matches!(
1195                    rate_backend,
1196                    &crate::api::RateBackend::RosaPlus { .. }
1197                ));
1198                assert_eq!(*coder, crate::coders::CoderType::AC);
1199                assert_eq!(*framing, crate::compression::FramingMode::Raw);
1200            }
1201            other => panic!(
1202                "unexpected default search compression backend: {:?}",
1203                other.kind()
1204            ),
1205        }
1206    }
1207
1208    #[test]
1209    fn search_with_universal_prior_modes_keeps_exact_match_first_for_generic_rate_backend() {
1210        let target_root = temp_path("prior-modes-target");
1211        let prior_root = temp_path("prior-modes-prior");
1212        fs::create_dir_all(&target_root).expect("create target dir");
1213        fs::create_dir_all(&prior_root).expect("create prior dir");
1214
1215        let relevant_path = target_root.join("relevant.txt");
1216        let distractor_path = target_root.join("distractor.txt");
1217        write_text(
1218            &relevant_path,
1219            "needle signal exact match\nneedle signal exact match\nneedle signal exact match\n",
1220        );
1221        write_text(
1222            &distractor_path,
1223            "unrelated noise\nentropy without the exact query phrase\n",
1224        );
1225        write_text(
1226            &prior_root.join("prior.txt"),
1227            "needle signal context\nbackground corpus bytes\n",
1228        );
1229
1230        for mode in [
1231            Stage2PriorMode::Disable,
1232            Stage2PriorMode::Use,
1233            Stage2PriorMode::Summarize,
1234        ] {
1235            let opts = SearchOptions {
1236                granularity: SearchGranularity::File,
1237                universal_prior: Some(prior_root.to_string_lossy().to_string()),
1238                stage2_prior_mode: mode,
1239                top_k: 2,
1240                stage0_keep_frac: 1.0,
1241                ctx: ctw_search_ctx(),
1242            };
1243            let results = search_with_options(
1244                "needle signal exact match",
1245                target_root.to_string_lossy().as_ref(),
1246                &opts,
1247            )
1248            .expect("search with prior should succeed");
1249            assert_eq!(results.len(), 2);
1250            assert_eq!(results[0].path, relevant_path);
1251            assert!(
1252                results[0].score >= results[1].score,
1253                "results must remain score-sorted after reranking for mode {mode:?}"
1254            );
1255        }
1256
1257        let _ = fs::remove_dir_all(target_root);
1258        let _ = fs::remove_dir_all(prior_root);
1259    }
1260
1261    #[cfg(feature = "backend-rosa")]
1262    #[test]
1263    fn search_with_rosa_prior_model_training_ranks_relevant_file_first() {
1264        let target_root = temp_path("rosa-prior-target");
1265        let prior_root = temp_path("rosa-prior-corpus");
1266        fs::create_dir_all(&target_root).expect("create target dir");
1267        fs::create_dir_all(&prior_root).expect("create prior dir");
1268
1269        let relevant_path = target_root.join("relevant.txt");
1270        write_text(
1271            &relevant_path,
1272            "predictive coding with exact entropy reduction signal\n\
1273             predictive coding with exact entropy reduction signal\n\
1274             predictive coding with exact entropy reduction signal\n",
1275        );
1276        write_text(
1277            &target_root.join("noise.txt"),
1278            "generic unrelated text that should not outrank the exact match\n",
1279        );
1280        write_text(
1281            &prior_root.join("prior_a.txt"),
1282            "predictive coding prior corpus\nentropy reduction prior corpus\n",
1283        );
1284        write_text(
1285            &prior_root.join("prior_b.txt"),
1286            "additional prior conditioning bytes for the rosa branch\n",
1287        );
1288
1289        let mut opts = SearchOptions::try_default().expect("search defaults");
1290        opts.granularity = SearchGranularity::File;
1291        opts.universal_prior = Some(prior_root.to_string_lossy().to_string());
1292        opts.stage2_prior_mode = Stage2PriorMode::Use;
1293        opts.top_k = 2;
1294        opts.stage0_keep_frac = 1.0;
1295
1296        let first = search_with_options(
1297            "predictive coding exact entropy reduction signal",
1298            target_root.to_string_lossy().as_ref(),
1299            &opts,
1300        )
1301        .expect("rosa prior search should succeed");
1302        let second = search_with_options(
1303            "predictive coding exact entropy reduction signal",
1304            target_root.to_string_lossy().as_ref(),
1305            &opts,
1306        )
1307        .expect("repeated rosa prior search should stay valid");
1308
1309        assert_eq!(first[0].path, relevant_path);
1310        assert_eq!(second[0].path, relevant_path);
1311        assert!(
1312            first[0].score.is_finite() && second[0].score.is_finite(),
1313            "rosa prior branch must produce finite scores"
1314        );
1315
1316        let _ = fs::remove_dir_all(target_root);
1317        let _ = fs::remove_dir_all(prior_root);
1318    }
1319
1320    #[test]
1321    fn corpus_bytes_and_prior_cache_helpers_are_stable() {
1322        let root = temp_path("corpus-root");
1323        fs::create_dir_all(&root).expect("create corpus dir");
1324        write_text(&root.join("a.txt"), "alpha");
1325        write_text(&root.join("b.txt"), "beta");
1326
1327        let bytes = corpus_bytes(root.to_string_lossy().as_ref(), SearchGranularity::File);
1328        assert!(bytes.windows(5).any(|window| window == b"alpha"));
1329        assert!(bytes.windows(4).any(|window| window == b"beta"));
1330        assert!(bytes.windows(2).any(|window| window == b"\n\n"));
1331
1332        let cache_a = prior_cache_path(root.to_string_lossy().as_ref(), 7).expect("cache path");
1333        let cache_b = prior_cache_path(root.to_string_lossy().as_ref(), 7).expect("cache path");
1334        let cache_c = prior_cache_path(root.to_string_lossy().as_ref(), 9).expect("cache path");
1335        assert_eq!(cache_a, cache_b);
1336        assert_ne!(cache_a, cache_c);
1337
1338        let _ = fs::remove_dir_all(root);
1339    }
1340
1341    #[test]
1342    fn summarize_prior_for_query_returns_empty_when_prior_corpus_is_empty() {
1343        let root = temp_path("empty-prior");
1344        fs::create_dir_all(&root).expect("create empty prior dir");
1345        let opts = SearchOptions {
1346            granularity: SearchGranularity::File,
1347            top_k: 1,
1348            stage0_keep_frac: 1.0,
1349            ctx: ctw_search_ctx(),
1350            ..SearchOptions::try_default().expect("search defaults")
1351        };
1352        let summary = summarize_prior_for_query(b"query", root.to_string_lossy().as_ref(), &opts)
1353            .expect("summarization should succeed");
1354        assert!(summary.is_empty());
1355        let _ = fs::remove_dir_all(root);
1356    }
1357
1358    #[test]
1359    fn summarize_prior_for_query_and_stage2_rerank_prefer_relevant_content() {
1360        let prior_root = temp_path("prior-summary");
1361        fs::create_dir_all(&prior_root).expect("create prior dir");
1362        write_text(
1363            &prior_root.join("relevant.txt"),
1364            "needle exact search phrase appears here\nneedle exact search phrase appears here\n",
1365        );
1366        write_text(
1367            &prior_root.join("noise.txt"),
1368            "background corpus bytes with unrelated content\n",
1369        );
1370
1371        let opts = SearchOptions {
1372            granularity: SearchGranularity::File,
1373            universal_prior: Some(prior_root.to_string_lossy().to_string()),
1374            stage2_prior_mode: Stage2PriorMode::Summarize,
1375            top_k: 2,
1376            stage0_keep_frac: 1.0,
1377            ctx: ctw_search_ctx(),
1378        };
1379        let query = b"needle exact search phrase";
1380        let summary =
1381            summarize_prior_for_query(query, prior_root.to_string_lossy().as_ref(), &opts)
1382                .expect("prior summary");
1383        assert!(
1384            String::from_utf8_lossy(&summary).contains("needle exact search phrase"),
1385            "summary should select the relevant prior candidate"
1386        );
1387
1388        let mut snippets = vec![
1389            Snippet {
1390                path: prior_root.join("noise.txt"),
1391                start_line: 1,
1392                end_line: 1,
1393                content: b"completely unrelated background".to_vec(),
1394                score: 0.0,
1395            },
1396            Snippet {
1397                path: prior_root.join("relevant.txt"),
1398                start_line: 1,
1399                end_line: 1,
1400                content: b"needle exact search phrase repeated".to_vec(),
1401                score: 0.0,
1402            },
1403        ];
1404        stage2_rerank_kmi(query, &mut snippets, &opts).expect("stage2 rerank");
1405        snippets.sort_by(|lhs, rhs| rhs.score.total_cmp(&lhs.score));
1406        assert_eq!(snippets[0].path, prior_root.join("relevant.txt"));
1407
1408        let _ = fs::remove_dir_all(prior_root);
1409    }
1410
1411    #[cfg(feature = "backend-rosa")]
1412    #[test]
1413    fn load_or_train_prior_model_creates_and_reuses_cache() {
1414        let prior_root = temp_path("rosa-cache-corpus");
1415        let cache_root = temp_path("rosa-cache-home");
1416        fs::create_dir_all(&prior_root).expect("create prior dir");
1417        fs::create_dir_all(&cache_root).expect("create cache dir");
1418        write_text(
1419            &prior_root.join("prior.txt"),
1420            "predictive coding prior text\npredictive coding prior text\n",
1421        );
1422
1423        let old_cache = std::env::var("XDG_CACHE_HOME").ok();
1424        // Test-only process environment override. This test does not spawn
1425        // threads or retain references into the environment across mutation.
1426        unsafe {
1427            std::env::set_var("XDG_CACHE_HOME", &cache_root);
1428        }
1429
1430        let opts = SearchOptions {
1431            granularity: SearchGranularity::File,
1432            universal_prior: Some(prior_root.to_string_lossy().to_string()),
1433            stage2_prior_mode: Stage2PriorMode::Use,
1434            top_k: 1,
1435            stage0_keep_frac: 1.0,
1436            ctx: InfotheoryCtx::from_specs(
1437                RateBackend::RosaPlus { max_order: 7 },
1438                CompressionBackend::Rate {
1439                    rate_backend: RateBackend::RosaPlus { max_order: 7 },
1440                    coder: crate::coders::CoderType::AC,
1441                    framing: crate::compression::FramingMode::Raw,
1442                },
1443            )
1444            .expect("rosa search context"),
1445        };
1446
1447        let prior_path = prior_root.to_string_lossy().to_string();
1448        let cache_path = prior_cache_path(&prior_path, 7).expect("cache path");
1449        assert!(!cache_path.exists());
1450
1451        let first = load_or_train_prior_model(&prior_path, &opts);
1452        assert!(cache_path.exists(), "training should populate cache");
1453        let second = load_or_train_prior_model(&prior_path, &opts);
1454        assert_eq!(first.lm_alpha_n(), 256);
1455        assert_eq!(second.lm_alpha_n(), 256);
1456
1457        match old_cache {
1458            Some(value) => unsafe {
1459                // Restore the original process environment after the isolated
1460                // cache-path test finishes.
1461                std::env::set_var("XDG_CACHE_HOME", value);
1462            },
1463            None => unsafe {
1464                // Restore the pre-test absence of `XDG_CACHE_HOME`.
1465                std::env::remove_var("XDG_CACHE_HOME");
1466            },
1467        }
1468
1469        let _ = fs::remove_dir_all(prior_root);
1470        let _ = fs::remove_dir_all(cache_root);
1471    }
1472}