Skip to main content

packset_daemon/
embed.rs

1//! The dense projection as a kept child process (`packset-embed`), one per
2//! direction. An absent encoder is a supported state: every failure falls
3//! back to the lexical scorers. A vector is derivable from the text, never
4//! the store.
5
6use 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
15/// Environment variables naming the encoder.
16pub const BIN_VARS: &[&str] = &["PACKSET_EMBED"];
17
18/// The encoder binary this seat would run: `PACKSET_EMBED`, else beside the
19/// writer, else on `PATH`.
20#[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    // Beside this binary, which is where a seat that installs the pair puts it.
32    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
65/// A running encoder: one child, one line in, one line out.
66struct 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        // One child encodes both sides. `--query` is still accepted by the
75        // binary for old callers; this writer sends `query` per line instead.
76        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    /// One question against many candidates in one line, one score each; the
111    /// child packs the batch into one forward pass.
112    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        // A short answer is a mismatch between what was asked and what came
123        // back, and padding it would silently score the tail as zero.
124        (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    /// One line in, the learned term weights out.
145    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        // Ascending by index, which is what lets two of them intersect in one
160        // pass; the model does not promise an order.
161        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    /// One line in, one line carrying all three forms out.
183    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    /// Write one request and read its one-line reply.
234    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    /// One line written, one line read back.
244    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    /// Whether the child is still there to be asked.
292    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
304/// Learned term weights: which vocabulary entries a text activates, and how
305/// much. Ascending by index, so two of them intersect in one pass.
306pub type Sparse = Vec<(u32, f32)>;
307
308/// One kept encoder. Query and document share it; the prefix is per line.
309type Slot = Mutex<Option<Encoder>>;
310
311/// The one dense child. `PACKSET_EMBED_QUERY_WORKERS` > 1 is extra models
312/// in RAM for hosts that asked.
313fn 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/// Encode one text, or nothing when this seat has no working encoder. A dead
322/// child is replaced once and the text retried.
323#[must_use]
324/// How many query encoders the writer keeps. One encoder is one model in
325/// RAM. Default 1. `PACKSET_EMBED_QUERY_WORKERS` raises it; a pool of two
326/// was 4 GB on a laptop that also held a document encoder.
327fn 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
335/// The query encoders: the first one free answers; when all are busy the
336/// caller waits on the first, which keeps every slot warm and none idle.
337fn 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
349/// Start every query encoder now, side by side, so the first agents to ask
350/// at once do not each pay a model load. Each probe holds one slot while it
351/// runs, which is what makes the pool spread rather than stack.
352pub 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/// Encode one query.
468#[must_use]
469pub fn encode_query(text: &str) -> Option<Vec<f32>> {
470    encode(text, true)
471}
472
473/// Encode one atom's text.
474#[must_use]
475pub fn encode_document(text: &str) -> Option<Vec<f32>> {
476    encode(text, false)
477}
478
479/// The kept encoder for the per-token form, which is a third child.
480fn late_slot() -> &'static Slot {
481    static LATE: OnceLock<Slot> = OnceLock::new();
482    LATE.get_or_init(|| Mutex::new(None))
483}
484
485/// Encode one text three ways from one pass: a vector per token, the pooled
486/// vector, and learned term weights. Read by the retrieval benchmark only.
487#[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
510/// The kept learned-sparse encoder, a fifth child.
511fn sparse_slot() -> &'static Slot {
512    static SPARSE: OnceLock<Slot> = OnceLock::new();
513    SPARSE.get_or_init(|| Mutex::new(None))
514}
515
516/// Learned term weights from SPLADE (doi:10.1145/3404835.3463098), a model
517/// trained for the weights, as opposed to the sparse head [`encode_late`]
518/// returns beside a dense vector. Read by the benchmark, not the writer.
519#[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
542/// How deep the second stage reads: the deepest cut-off the locomo table scores.
543pub const RERANK_DEPTH: usize = 20;
544
545/// Whether the live search path runs the second stage by default. Off unless
546/// asked; the same spellings `/v1/search?rerank=` accepts.
547#[must_use]
548pub fn wanted() -> bool {
549    flag_on(&std::env::var("PACKSET_RERANK").unwrap_or_default())
550}
551
552/// Whether one request runs the stage: the query flag over the host default.
553#[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/// Reorder the head of a ranking by cross-encoder scores. `scores` must cover
569/// `RERANK_DEPTH.min(hits.len())`; the tail keeps its order; ties are stable.
570#[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/// Reorder the top of a ranking by a cross-encoder; `None` when there is no
588/// working reranker, so the caller keeps the ranking and says so.
589#[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
608/// The kept cross-encoder, a fourth child.
609fn rerank_slot() -> &'static Slot {
610    static RERANK: OnceLock<Slot> = OnceLock::new();
611    RERANK.get_or_init(|| Mutex::new(None))
612}
613
614/// Cross-encoder scores for every candidate against the question
615/// (doi:10.48550/arXiv.1901.04085), in the caller's order. Not the panel's
616/// `rerank`, which is diversification.
617#[must_use]
618pub fn rerank(question: &str, candidates: &[String]) -> Option<Vec<f32>> {
619    if question.trim().is_empty() {
620        return None;
621    }
622    // Nothing to score is not a failure, and it must not cost a model call.
623    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    /// No candidates is an empty ballot, not a missing reranker.
670    #[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        // An empty question is refused before any child is started, the same
674        // way an empty text is never sent to an encoder.
675        assert!(rerank("", &["a candidate".to_string()]).is_none());
676        assert!(rerank("   ", &["a candidate".to_string()]).is_none());
677    }
678
679    /// A short reply is refused, not padded with zeros.
680    #[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        // `cat` echoes what it is given, so the reply carries the request's
693        // own fields and no `s` at all: a well-formed line that is not an
694        // answer.
695        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    /// The second stage is off unless the host asks. An unset env is the
718    /// shipped default; a process that already exported PACKSET_RERANK is a
719    /// different seat and this test does not speak for it.
720    #[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    /// A stub child that scores later candidates higher, then `apply_rerank`.
772    #[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}