1use std::io::{BufRead, BufReader, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Child, ChildStdin, Command, Stdio};
9use std::sync::mpsc::sync_channel;
10use std::sync::{Condvar, Mutex, OnceLock};
11use std::time::Duration;
12
13use serde_json::{json, Value};
14
15pub const BIN_VARS: &[&str] = &["PACKSET_EMBED"];
17
18#[must_use]
21pub fn binary() -> Option<PathBuf> {
22 for var in BIN_VARS {
23 if let Some(raw) = std::env::var_os(var) {
24 let path = PathBuf::from(raw);
25 if is_executable(&path) {
26 return Some(path);
27 }
28 }
29 }
30 let here = std::env::current_exe().ok()?;
31 if let Some(beside) = here
33 .parent()
34 .map(|dir| dir.join("packset-embed"))
35 .filter(|path| is_executable(path))
36 {
37 return Some(beside);
38 }
39 if let Some(root) = here.parent().and_then(Path::parent).and_then(Path::parent) {
40 for candidate in [
41 root.join("bin/packset-embed"),
42 root.join("crates/packset-embed/target/release/packset-embed"),
43 ] {
44 if is_executable(&candidate) {
45 return Some(candidate);
46 }
47 }
48 }
49 which("packset-embed")
50}
51
52fn which(name: &str) -> Option<PathBuf> {
53 let paths = std::env::var_os("PATH")?;
54 std::env::split_paths(&paths)
55 .map(|dir| dir.join(name))
56 .find(|path| is_executable(path))
57}
58
59fn is_executable(path: &Path) -> bool {
60 use std::os::unix::fs::PermissionsExt;
61 std::fs::metadata(path)
62 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
63}
64
65struct Encoder {
67 child: Child,
68 stdin: ChildStdin,
69 stdout: BufReader<std::process::ChildStdout>,
70}
71
72impl Encoder {
73 fn start(binary: &Path, query: bool) -> Option<Self> {
74 let _ = query;
77 let mut command = Command::new(binary);
78 let mut child = command
79 .stdin(Stdio::piped())
80 .stdout(Stdio::piped())
81 .stderr(Stdio::null())
82 .spawn()
83 .ok()?;
84 let stdin = child.stdin.take()?;
85 let stdout = BufReader::new(child.stdout.take()?);
86 Some(Self {
87 child,
88 stdin,
89 stdout,
90 })
91 }
92
93 fn start_rerank(binary: &Path) -> Option<Self> {
94 let mut child = Command::new(binary)
95 .arg("--rerank")
96 .stdin(Stdio::piped())
97 .stdout(Stdio::piped())
98 .stderr(Stdio::null())
99 .spawn()
100 .ok()?;
101 let stdin = child.stdin.take()?;
102 let stdout = BufReader::new(child.stdout.take()?);
103 Some(Self {
104 child,
105 stdin,
106 stdout,
107 })
108 }
109
110 fn rerank(&mut self, question: &str, candidates: &[String]) -> Option<Vec<f32>> {
113 let asked = serde_json::json!({ "id": "q", "q": question, "d": candidates });
114 let reply = self.ask_json(&asked.to_string())?;
115 let parsed: Value = serde_json::from_str(reply.trim()).ok()?;
116 let scores: Vec<f32> = parsed
117 .get("s")?
118 .as_array()?
119 .iter()
120 .map(|v| v.as_f64().unwrap_or(0.0) as f32)
121 .collect();
122 (scores.len() == candidates.len()).then_some(scores)
125 }
126
127 fn start_sparse(binary: &Path) -> Option<Self> {
128 let mut child = Command::new(binary)
129 .arg("--sparse")
130 .stdin(Stdio::piped())
131 .stdout(Stdio::piped())
132 .stderr(Stdio::null())
133 .spawn()
134 .ok()?;
135 let stdin = child.stdin.take()?;
136 let stdout = BufReader::new(child.stdout.take()?);
137 Some(Self {
138 child,
139 stdin,
140 stdout,
141 })
142 }
143
144 fn encode_sparse(&mut self, text: &str) -> Option<Sparse> {
146 let reply = self.ask(text)?;
147 let parsed: Value = serde_json::from_str(reply.trim()).ok()?;
148 let s = parsed.get("s")?;
149 let indices = s.get("i")?.as_array()?;
150 let weights = s.get("w")?.as_array()?;
151 if indices.len() != weights.len() {
152 return None;
153 }
154 let mut pairs: Sparse = indices
155 .iter()
156 .zip(weights)
157 .filter_map(|(i, w)| Some((i.as_u64()? as u32, w.as_f64()? as f32)))
158 .collect();
159 pairs.sort_unstable_by_key(|(index, _)| *index);
162 Some(pairs)
163 }
164
165 fn start_late(binary: &Path) -> Option<Self> {
166 let mut child = Command::new(binary)
167 .arg("--late")
168 .stdin(Stdio::piped())
169 .stdout(Stdio::piped())
170 .stderr(Stdio::null())
171 .spawn()
172 .ok()?;
173 let stdin = child.stdin.take()?;
174 let stdout = BufReader::new(child.stdout.take()?);
175 Some(Self {
176 child,
177 stdin,
178 stdout,
179 })
180 }
181
182 fn encode_tokens(&mut self, text: &str) -> Option<(Vec<Vec<f32>>, Vec<f32>, Sparse)> {
184 let reply = self.ask(text)?;
185 let parsed: Value = serde_json::from_str(reply.trim()).ok()?;
186 let rows = parsed.get("t")?.as_array()?;
187 let tokens: Vec<Vec<f32>> = rows
188 .iter()
189 .filter_map(|row| {
190 let vector: Vec<f32> = row
191 .as_array()?
192 .iter()
193 .filter_map(|v| v.as_f64().map(|f| f as f32))
194 .collect();
195 (!vector.is_empty()).then_some(vector)
196 })
197 .collect();
198 let pooled: Vec<f32> = parsed
199 .get("v")
200 .and_then(Value::as_array)
201 .map(|items| {
202 items
203 .iter()
204 .filter_map(|v| v.as_f64().map(|f| f as f32))
205 .collect()
206 })
207 .unwrap_or_default();
208 let sparse = parsed
209 .get("s")
210 .map(|raw| {
211 let indices = raw
212 .get("i")
213 .and_then(Value::as_array)
214 .map(Vec::as_slice)
215 .unwrap_or_default()
216 .iter()
217 .filter_map(|value| value.as_u64().map(|index| index as u32));
218 let weights = raw
219 .get("w")
220 .and_then(Value::as_array)
221 .map(Vec::as_slice)
222 .unwrap_or_default()
223 .iter()
224 .filter_map(|value| value.as_f64().map(|weight| weight as f32));
225 indices.zip(weights).collect()
226 })
227 .unwrap_or_default();
228 let mut sparse: Sparse = sparse;
229 sparse.sort_unstable_by_key(|(index, _)| *index);
230 (!tokens.is_empty()).then_some((tokens, pooled, sparse))
231 }
232
233 fn ask(&mut self, text: &str) -> Option<String> {
235 self.ask_text(text, false)
236 }
237
238 fn ask_text(&mut self, text: &str, query: bool) -> Option<String> {
239 let line = json!({ "id": "0", "text": text, "query": query });
240 self.ask_json(&line.to_string())
241 }
242
243 fn ask_json(&mut self, line: &str) -> Option<String> {
245 writeln!(self.stdin, "{line}").ok()?;
246 self.stdin.flush().ok()?;
247 let mut reply = String::new();
248 if self.stdout.read_line(&mut reply).ok()? == 0 {
249 return None;
250 }
251 Some(reply)
252 }
253
254 fn encode_batch(&mut self, texts: &[String], query: bool) -> Option<Vec<Vec<f32>>> {
255 if texts.is_empty() {
256 return Some(Vec::new());
257 }
258 if texts.len() == 1 {
259 return self.encode_as(&texts[0], query).map(|v| vec![v]);
260 }
261 let line = json!({ "id": "b", "query": query, "texts": texts });
262 let reply = self.ask_json(&line.to_string())?;
263 let parsed: Value = serde_json::from_str(reply.trim()).ok()?;
264 let rows = parsed.get("vs")?.as_array()?;
265 let out: Vec<Vec<f32>> = rows
266 .iter()
267 .filter_map(|row| {
268 Some(
269 row.as_array()?
270 .iter()
271 .filter_map(|x| x.as_f64().map(|f| f as f32))
272 .collect(),
273 )
274 })
275 .collect();
276 (out.len() == texts.len()).then_some(out)
277 }
278
279 fn encode_as(&mut self, text: &str, query: bool) -> Option<Vec<f32>> {
280 let reply = self.ask_text(text, query)?;
281 let parsed: Value = serde_json::from_str(reply.trim()).ok()?;
282 let vector: Vec<f32> = parsed
283 .get("v")?
284 .as_array()?
285 .iter()
286 .filter_map(|v| v.as_f64().map(|f| f as f32))
287 .collect();
288 (!vector.is_empty()).then_some(vector)
289 }
290
291 fn alive(&mut self) -> bool {
293 matches!(self.child.try_wait(), Ok(None))
294 }
295}
296
297impl Drop for Encoder {
298 fn drop(&mut self) {
299 let _ = self.child.kill();
300 let _ = self.child.wait();
301 }
302}
303
304pub type Sparse = Vec<(u32, f32)>;
307
308type Slot = Mutex<Option<Encoder>>;
310
311fn dense_slot() -> &'static Slot {
314 if query_workers() <= 1 {
315 static ONE: OnceLock<Slot> = OnceLock::new();
316 return ONE.get_or_init(|| Mutex::new(None));
317 }
318 query_slot()
319}
320
321#[must_use]
324fn query_workers() -> usize {
328 std::env::var("PACKSET_EMBED_QUERY_WORKERS")
329 .ok()
330 .and_then(|raw| raw.trim().parse().ok())
331 .filter(|n: &usize| *n >= 1)
332 .unwrap_or(1)
333}
334
335fn query_slot() -> &'static Slot {
338 static POOL: OnceLock<Vec<Slot>> = OnceLock::new();
339 let pool = POOL.get_or_init(|| (0..query_workers()).map(|_| Mutex::new(None)).collect());
340 for s in pool {
341 if let Ok(guard) = s.try_lock() {
342 drop(guard);
343 return s;
344 }
345 }
346 &pool[0]
347}
348
349pub fn warm_queries() {
353 let workers = query_workers();
354 let hands: Vec<_> = (0..workers)
355 .map(|_| std::thread::spawn(|| encode_query("the pack is open")))
356 .collect();
357 for hand in hands {
358 let _ = hand.join();
359 }
360}
361
362struct Pending {
363 text: String,
364 query: bool,
365 tx: std::sync::mpsc::SyncSender<Option<Vec<f32>>>,
366}
367
368fn pending() -> &'static (Mutex<Vec<Pending>>, Condvar) {
369 static Q: OnceLock<(Mutex<Vec<Pending>>, Condvar)> = OnceLock::new();
370 Q.get_or_init(|| (Mutex::new(Vec::new()), Condvar::new()))
371}
372
373fn ensure_pump() {
374 static START: OnceLock<()> = OnceLock::new();
375 START.get_or_init(|| {
376 let _ = std::thread::Builder::new()
377 .name("packset-embed-pump".into())
378 .spawn(pump);
379 });
380}
381
382fn pump() {
383 let (lock, cv) = pending();
384 loop {
385 let mut held = match lock.lock() {
386 Ok(g) => g,
387 Err(_) => return,
388 };
389 while held.is_empty() {
390 held = match cv.wait(held) {
391 Ok(g) => g,
392 Err(_) => return,
393 };
394 }
395 drop(held);
396 std::thread::sleep(Duration::from_millis(2));
397 let batch = match lock.lock() {
398 Ok(mut g) => std::mem::take(&mut *g),
399 Err(_) => return,
400 };
401 dispatch(batch);
402 }
403}
404
405fn dispatch(batch: Vec<Pending>) {
406 let mut queries = Vec::new();
407 let mut docs = Vec::new();
408 for job in batch {
409 if job.query {
410 queries.push(job);
411 } else {
412 docs.push(job);
413 }
414 }
415 run_group(queries, true);
416 run_group(docs, false);
417}
418
419fn run_group(jobs: Vec<Pending>, query: bool) {
420 if jobs.is_empty() {
421 return;
422 }
423 let texts: Vec<String> = jobs.iter().map(|j| j.text.clone()).collect();
424 let vecs = encode_now(&texts, query);
425 let mut answers = vecs.unwrap_or_default().into_iter();
426 for job in jobs {
427 let _ = job.tx.send(answers.next());
428 }
429}
430
431fn encode_now(texts: &[String], query: bool) -> Option<Vec<Vec<f32>>> {
432 let binary = binary()?;
433 let mut held = dense_slot().lock().ok()?;
434 for _ in 0..2 {
435 if held.as_mut().is_none_or(|running| !running.alive()) {
436 *held = Encoder::start(&binary, query);
437 }
438 let running = held.as_mut()?;
439 if let Some(vectors) = running.encode_batch(texts, query) {
440 return Some(vectors);
441 }
442 *held = None;
443 }
444 None
445}
446
447pub fn encode(text: &str, query: bool) -> Option<Vec<f32>> {
448 if text.trim().is_empty() {
449 return None;
450 }
451 binary()?;
452 ensure_pump();
453 let (tx, rx) = sync_channel(1);
454 {
455 let (lock, cv) = pending();
456 let mut q = lock.lock().ok()?;
457 q.push(Pending {
458 text: text.to_string(),
459 query,
460 tx,
461 });
462 cv.notify_one();
463 }
464 rx.recv().ok()?
465}
466
467#[must_use]
469pub fn encode_query(text: &str) -> Option<Vec<f32>> {
470 encode(text, true)
471}
472
473#[must_use]
475pub fn encode_document(text: &str) -> Option<Vec<f32>> {
476 encode(text, false)
477}
478
479fn late_slot() -> &'static Slot {
481 static LATE: OnceLock<Slot> = OnceLock::new();
482 LATE.get_or_init(|| Mutex::new(None))
483}
484
485#[must_use]
488pub fn encode_late(text: &str) -> Option<(Vec<Vec<f32>>, Vec<f32>, Sparse)> {
489 if text.trim().is_empty() {
490 return None;
491 }
492 let binary = binary()?;
493 let mut held = late_slot().lock().ok()?;
494 for attempt in 0..2 {
495 if held.as_mut().is_none_or(|running| !running.alive()) {
496 *held = Encoder::start_late(&binary);
497 }
498 let running = held.as_mut()?;
499 if let Some(both) = running.encode_tokens(text) {
500 return Some(both);
501 }
502 *held = None;
503 if attempt == 1 {
504 return None;
505 }
506 }
507 None
508}
509
510fn sparse_slot() -> &'static Slot {
512 static SPARSE: OnceLock<Slot> = OnceLock::new();
513 SPARSE.get_or_init(|| Mutex::new(None))
514}
515
516#[must_use]
520pub fn encode_sparse(text: &str) -> Option<Sparse> {
521 if text.trim().is_empty() {
522 return None;
523 }
524 let binary = binary()?;
525 let mut held = sparse_slot().lock().ok()?;
526 for attempt in 0..2 {
527 if held.as_mut().is_none_or(|running| !running.alive()) {
528 *held = Encoder::start_sparse(&binary);
529 }
530 let running = held.as_mut()?;
531 if let Some(weights) = running.encode_sparse(text) {
532 return Some(weights);
533 }
534 *held = None;
535 if attempt == 1 {
536 return None;
537 }
538 }
539 None
540}
541
542pub const RERANK_DEPTH: usize = 20;
544
545#[must_use]
548pub fn wanted() -> bool {
549 flag_on(&std::env::var("PACKSET_RERANK").unwrap_or_default())
550}
551
552#[must_use]
554pub fn requested(query: Option<&str>) -> bool {
555 match query.map(str::trim).filter(|s| !s.is_empty()) {
556 Some(raw) => flag_on(raw),
557 None => wanted(),
558 }
559}
560
561fn flag_on(raw: &str) -> bool {
562 matches!(
563 raw.trim().to_ascii_lowercase().as_str(),
564 "1" | "true" | "yes"
565 )
566}
567
568#[must_use]
571pub fn apply_rerank(hits: &[Value], scores: &[f32]) -> Option<Vec<Value>> {
572 let depth = RERANK_DEPTH.min(hits.len());
573 if scores.len() != depth {
574 return None;
575 }
576 let mut head: Vec<(f32, Value)> = scores
577 .iter()
578 .copied()
579 .zip(hits[..depth].iter().cloned())
580 .collect();
581 head.sort_by(|a, b| b.0.total_cmp(&a.0));
582 let mut out: Vec<Value> = head.into_iter().map(|(_, hit)| hit).collect();
583 out.extend_from_slice(&hits[depth..]);
584 Some(out)
585}
586
587#[must_use]
590pub fn rerank_hits(question: &str, hits: &[Value]) -> Option<Vec<Value>> {
591 if hits.is_empty() {
592 return Some(Vec::new());
593 }
594 let depth = RERANK_DEPTH.min(hits.len());
595 let candidates: Vec<String> = hits[..depth]
596 .iter()
597 .map(|hit| {
598 hit.get("text")
599 .and_then(Value::as_str)
600 .unwrap_or_default()
601 .to_string()
602 })
603 .collect();
604 let scores = rerank(question, &candidates)?;
605 apply_rerank(hits, &scores)
606}
607
608fn rerank_slot() -> &'static Slot {
610 static RERANK: OnceLock<Slot> = OnceLock::new();
611 RERANK.get_or_init(|| Mutex::new(None))
612}
613
614#[must_use]
618pub fn rerank(question: &str, candidates: &[String]) -> Option<Vec<f32>> {
619 if question.trim().is_empty() {
620 return None;
621 }
622 if candidates.is_empty() {
624 return Some(Vec::new());
625 }
626 let binary = binary()?;
627 let mut held = rerank_slot().lock().ok()?;
628 for attempt in 0..2 {
629 if held.as_mut().is_none_or(|running| !running.alive()) {
630 *held = Encoder::start_rerank(&binary);
631 }
632 let running = held.as_mut()?;
633 if let Some(scores) = running.rerank(question, candidates) {
634 return Some(scores);
635 }
636 *held = None;
637 if attempt == 1 {
638 return None;
639 }
640 }
641 None
642}
643
644#[cfg(test)]
645pub fn reset_for_test() {
646 for slot in [dense_slot(), rerank_slot(), late_slot(), sparse_slot()] {
647 if let Ok(mut held) = slot.lock() {
648 *held = None;
649 }
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn a_path_that_is_not_a_program_is_not_an_encoder() {
659 assert!(!is_executable(&PathBuf::from("/nonexistent/packset-embed")));
660 assert!(!is_executable(&PathBuf::from("/etc")));
661 }
662
663 #[test]
664 fn empty_text_is_never_sent_to_a_model() {
665 assert!(encode("", false).is_none());
666 assert!(encode(" ", true).is_none());
667 }
668
669 #[test]
671 fn nothing_to_rerank_is_an_empty_ballot_and_not_a_failure() {
672 assert_eq!(rerank("which search tool", &[]), Some(Vec::new()));
673 assert!(rerank("", &["a candidate".to_string()]).is_none());
676 assert!(rerank(" ", &["a candidate".to_string()]).is_none());
677 }
678
679 #[test]
681 fn a_short_reply_is_a_mismatch_rather_than_a_ranking() {
682 let Ok(mut child) = Command::new("cat")
683 .stdin(Stdio::piped())
684 .stdout(Stdio::piped())
685 .spawn()
686 else {
687 return;
688 };
689 let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
690 return;
691 };
692 let mut echoing = Encoder {
696 child,
697 stdin,
698 stdout: BufReader::new(stdout),
699 };
700 let candidates = vec!["one".to_string(), "two".to_string()];
701 assert!(echoing.rerank("a question", &candidates).is_none());
702 }
703
704 #[test]
705 fn a_child_that_exits_is_not_alive() {
706 let Ok(mut child) = Command::new("true").stdout(Stdio::piped()).spawn() else {
707 return;
708 };
709 let _ = child.wait();
710 assert!(matches!(child.try_wait(), Ok(Some(_))));
711 }
712
713 fn hit(id: &str, text: &str) -> Value {
714 json!({ "id": id, "text": text })
715 }
716
717 #[test]
721 fn the_second_stage_is_off_unless_asked() {
722 if std::env::var_os("PACKSET_RERANK").is_some() {
723 return;
724 }
725 assert!(!wanted());
726 assert!(!requested(None));
727 assert!(!requested(Some("")));
728 assert!(!requested(Some("0")));
729 assert!(!requested(Some("on")));
730 assert!(requested(Some("1")));
731 assert!(requested(Some("true")));
732 assert!(requested(Some("yes")));
733 }
734
735 #[test]
736 fn apply_rerank_promotes_the_higher_score_and_keeps_the_tail() {
737 let hits: Vec<Value> = (0..22)
738 .map(|i| hit(&format!("h{i}"), &format!("text {i}")))
739 .collect();
740 let mut scores = vec![0.0f32; RERANK_DEPTH];
741 scores[0] = 0.1;
742 scores[1] = 0.9;
743 let ranked = apply_rerank(&hits, &scores).expect("length matches");
744 assert_eq!(ranked[0]["id"], json!("h1"));
745 assert_eq!(ranked[1]["id"], json!("h0"));
746 assert_eq!(ranked[2]["id"], json!("h2"));
747 assert_eq!(ranked[20]["id"], json!("h20"));
748 assert_eq!(ranked[21]["id"], json!("h21"));
749 assert_eq!(ranked.len(), 22);
750 }
751
752 #[test]
753 fn a_tie_keeps_the_first_stage_order() {
754 let hits = vec![hit("first", "a"), hit("second", "b")];
755 let ranked = apply_rerank(&hits, &[0.5, 0.5]).expect("length matches");
756 assert_eq!(ranked[0]["id"], json!("first"));
757 assert_eq!(ranked[1]["id"], json!("second"));
758 }
759
760 #[test]
761 fn a_short_score_list_is_refused_rather_than_padded() {
762 let hits = vec![hit("a", "a"), hit("b", "b")];
763 assert!(apply_rerank(&hits, &[0.9]).is_none());
764 }
765
766 #[test]
767 fn nothing_to_reorder_is_an_empty_ranking() {
768 assert_eq!(rerank_hits("which search tool", &[]), Some(Vec::new()));
769 }
770
771 #[test]
773 fn a_child_that_scores_the_tail_first_reorders_the_head() {
774 let script =
775 std::env::temp_dir().join(format!("packset-rerank-stub-{}.py", std::process::id()));
776 let body = concat!(
777 "#!/usr/bin/env python3\n",
778 "import json, sys\n",
779 "for line in sys.stdin:\n",
780 " line = line.strip()\n",
781 " if not line:\n",
782 " continue\n",
783 " req = json.loads(line)\n",
784 " d = req.get('d', [])\n",
785 " print(json.dumps({'id': req.get('id', 'q'), 's': list(range(len(d)))}), flush=True)\n",
786 );
787 if std::fs::write(&script, body).is_err() {
788 return;
789 }
790 #[cfg(unix)]
791 {
792 use std::os::unix::fs::PermissionsExt;
793 let _ = std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755));
794 }
795 let Ok(mut child) = Command::new(&script)
796 .stdin(Stdio::piped())
797 .stdout(Stdio::piped())
798 .spawn()
799 else {
800 let _ = std::fs::remove_file(&script);
801 return;
802 };
803 let (Some(stdin), Some(stdout)) = (child.stdin.take(), child.stdout.take()) else {
804 let _ = std::fs::remove_file(&script);
805 return;
806 };
807 let mut enc = Encoder {
808 child,
809 stdin,
810 stdout: BufReader::new(stdout),
811 };
812 let candidates = vec!["first".into(), "second".into()];
813 let scores = enc.rerank("a question", &candidates);
814 drop(enc);
815 let _ = std::fs::remove_file(&script);
816 let scores = scores.expect("stub scored");
817 let hits = vec![hit("a", "first"), hit("b", "second")];
818 let ranked = apply_rerank(&hits, &scores).expect("length matches");
819 assert_eq!(ranked[0]["id"], json!("b"));
820 assert_eq!(ranked[1]["id"], json!("a"));
821 }
822}