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)]
14pub struct Snippet {
16 pub path: PathBuf,
18 pub start_line: usize,
20 pub end_line: usize,
22 pub content: Vec<u8>,
24 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 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)]
79pub enum SearchGranularity {
81 Snippet,
83 File,
85}
86
87#[derive(Debug, Clone, Copy, Eq, PartialEq)]
88pub enum Stage2PriorMode {
90 Use,
92 Disable,
94 Summarize,
96}
97
98#[derive(Clone)]
99pub struct SearchOptions {
101 pub granularity: SearchGranularity,
103 pub universal_prior: Option<String>,
107 pub stage2_prior_mode: Stage2PriorMode,
109 pub top_k: usize,
111 pub stage0_keep_frac: f64,
113 pub ctx: InfotheoryCtx,
118}
119
120pub const DEFAULT_SEARCH_RATE_BACKEND_NAME: &str = "rosaplus";
122#[cfg(feature = "backend-zpaq")]
124pub const DEFAULT_SEARCH_COMPRESSION_BACKEND_NAME: &str = "zpaq";
125#[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 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
147pub 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
153pub 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
180pub 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 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 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 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 let mut base = load_or_train_prior_model(prior_path, opts);
376 base.ensure_lm_built_no_finalize_endpos();
379 base.shrink_aux_buffers();
381
382 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 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 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 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 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 let k_q_given_x = (c_joint as f64 - cx as f64).max(0.0);
605 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 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 (5u32).hash(&mut hasher);
647 prior_path.hash(&mut hasher);
648 max_order.hash(&mut hasher);
649 ("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 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 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 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 let mut m = RosaPlus::new(max_order, false, 0, 42);
682 train_rosa_on_corpus(&mut m, prior_path, SearchGranularity::File);
683 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 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 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 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 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 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 std::env::set_var("XDG_CACHE_HOME", value);
1462 },
1463 None => unsafe {
1464 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}