Skip to main content

rlx_runtime/
lm.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Generic language-model runner trait and shared builder.
6//!
7//! Until now every `rlx-<family>` model crate carried its own
8//! `*RunnerBuilder` (Qwen3RunnerBuilder, Llama32RunnerBuilder, …)
9//! with the same fields, the same `*ConfigSource { Embedded |
10//! JsonFile | Explicit(T) }` enum, and the same auto-packed-GGUF
11//! heuristic. This module hoists those shapes upstream so that:
12//!
13//!   1. `LmRunner` can live in `rlx-runtime` (today's home in
14//!      `rlx-cli` forces every model crate to take a dependency on
15//!      the CLI helper crate).
16//!   2. Per-family runners can `Deref` to / wrap [`LmRunnerBuilder`]
17//!      instead of redefining the same fields.
18//!   3. Downstream tools (`skill`, web apps) can talk to runners
19//!      through one trait without compiling in every model crate.
20//!
21//! The trait surface mirrors the existing `rlx_cli::LmRunner`. The
22//! CLI re-export is kept for backwards compat.
23
24use std::path::{Path, PathBuf};
25
26use crate::Device;
27
28/// A model-agnostic decode-session snapshot: the KV cache plus the token
29/// history that produced it. Used by the prompt cache to reuse a prefix.
30#[derive(Debug, Clone)]
31pub struct SessionSnapshot {
32    pub kv: crate::kv_cache::LayerKvCache,
33    pub tokens: Vec<u32>,
34}
35
36/// Minimal per-family runner interface used by `auto_dispatch` and
37/// the `rlx-text` / `skill` integration.
38///
39/// Implementations must be `Send` so the boxed trait can move across
40/// threads (e.g. when a server runs inference on a worker pool).
41/// `Sync` is intentionally not required — runners hold mutable
42/// per-call compile / cache state.
43pub trait LmRunner: Send {
44    /// Short family identifier (`"qwen3"`, `"llama32"`, `"gemma"`).
45    fn family(&self) -> &'static str;
46
47    /// LM head vocabulary size.
48    fn vocab_size(&self) -> usize;
49
50    /// Run prefill on `prompt_ids` and return last-token logits.
51    fn predict_logits(&mut self, prompt_ids: &[u32]) -> anyhow::Result<Vec<f32>>;
52
53    /// Generate up to `n_new` tokens after `prompt_ids` using greedy
54    /// (argmax) sampling. The default impl re-prefills on the full
55    /// context each step — per-family runners should override with
56    /// their cached decode fast path.
57    ///
58    /// `on_token` returns `true` to continue, `false` to stop.
59    fn generate(
60        &mut self,
61        prompt_ids: &[u32],
62        n_new: usize,
63        on_token: &mut dyn FnMut(u32) -> bool,
64    ) -> anyhow::Result<Vec<u32>> {
65        let mut context: Vec<u32> = prompt_ids.to_vec();
66        let mut produced: Vec<u32> = Vec::with_capacity(n_new);
67        for _ in 0..n_new {
68            let logits = self.predict_logits(&context)?;
69            let next = argmax_u32(&logits);
70            produced.push(next);
71            let cont = on_token(next);
72            context.push(next);
73            if !cont {
74                break;
75            }
76        }
77        Ok(produced)
78    }
79
80    /// Prefill `prompt_ids`, seed the decode KV cache, and return the
81    /// last-position logits `[vocab]`. Together with `decode_logits` this
82    /// gives a **host-driven** decode loop: the caller owns sampling, logit
83    /// bias, log-probs, and stop detection (used by the HTTP server). The
84    /// default reports unsupported so existing runners keep compiling.
85    fn prefill_logits(&mut self, _prompt_ids: &[u32]) -> anyhow::Result<Vec<f32>> {
86        Err(anyhow::anyhow!(
87            "{}: host-driven logits decode (prefill_logits) unsupported",
88            self.family()
89        ))
90    }
91
92    /// Feed one token, advance the KV cache, and return the next-position
93    /// logits `[vocab]`. See [`LmRunner::prefill_logits`]. Default: unsupported.
94    fn decode_logits(&mut self, _token: u32) -> anyhow::Result<Vec<f32>> {
95        Err(anyhow::anyhow!(
96            "{}: host-driven logits decode (decode_logits) unsupported",
97            self.family()
98        ))
99    }
100
101    /// Register a callback fired at multimodal-generation **phase boundaries** —
102    /// `"vision"` before the image is encoded and `"prefill"` before the LM
103    /// prefill — so callers can surface real progress during the pre-token
104    /// lead-in (vision encode + prefill are otherwise one opaque blocking call).
105    /// Default: no-op (text-only runners ignore it). Pass `None` to clear.
106    fn set_multimodal_phase_callback(
107        &mut self,
108        _cb: Option<std::sync::Arc<dyn Fn(&str) + Send + Sync>>,
109    ) {
110    }
111
112    /// Prefill `prompt` reusing a session snapshot that already covers its
113    /// first `reuse_len` tokens — only the suffix is processed. Returns the
114    /// last-position logits. The default ignores the snapshot and does a full
115    /// prefill, so callers always get correct logits even without reuse.
116    fn prefill_logits_reusing(
117        &mut self,
118        prompt: &[u32],
119        _snap: &SessionSnapshot,
120        _reuse_len: usize,
121    ) -> anyhow::Result<Vec<f32>> {
122        self.prefill_logits(prompt)
123    }
124
125    /// Snapshot this runner's decode session (KV cache + token history) for
126    /// prompt-cache reuse. `None` ⇒ the family doesn't support it.
127    fn export_session(&self) -> Option<SessionSnapshot> {
128        None
129    }
130
131    /// Restore a previously exported session so generation resumes from a
132    /// cached prefix. Returns `true` if applied.
133    fn restore_session(&mut self, _snap: &SessionSnapshot) -> bool {
134        false
135    }
136
137    /// Long-context KV context-store occupancy `(blocks, tokens, disk_bytes)`
138    /// when a store is enabled (e.g. rlx-qwen3 HNSW memory), else `None`. Lets a
139    /// host inspect/track how the store grows without knowing the concrete runner.
140    fn kv_store_stats(&self) -> Option<(usize, usize, usize)> {
141        None
142    }
143
144    /// Whether this runner supports multimodal (image+text) generation.
145    fn supports_multimodal(&self) -> bool {
146        false
147    }
148
149    /// Multimodal generation: prefill with text where image markers are
150    /// spliced with vision embeddings derived from `rgb`.
151    fn generate_multimodal(
152        &mut self,
153        _prompt: &str,
154        _rgb: &[u8],
155        _img_w: usize,
156        _img_h: usize,
157        _tokenizer: Option<&Path>,
158        _n_new: usize,
159        _on_token: &mut dyn FnMut(u32) -> bool,
160    ) -> anyhow::Result<Vec<u32>> {
161        Err(anyhow::anyhow!(
162            "this LmRunner does not support multimodal generation"
163        ))
164    }
165}
166
167fn argmax_u32(logits: &[f32]) -> u32 {
168    let mut best = 0usize;
169    let mut best_v = f32::NEG_INFINITY;
170    for (i, &v) in logits.iter().enumerate() {
171        if v > best_v {
172            best_v = v;
173            best = i;
174        }
175    }
176    best as u32
177}
178
179// ─────────────────────────────────────────────────────────────────
180// Weight format + config source
181// ─────────────────────────────────────────────────────────────────
182
183/// Weight file format. Detected from the file extension by default;
184/// the CLI accepts `--format` to override.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum WeightFormat {
187    Safetensors,
188    Gguf,
189    /// MLX / mlx-community layouts (dir, `.safetensors`, `.npz`, `.npy`).
190    Mlx,
191    /// HuggingFace DDUF (`.dduf` ZIP of nested safetensors).
192    Dduf,
193}
194
195impl WeightFormat {
196    /// Infer format from a path extension (or mlx-community directory).
197    pub fn from_path(path: &Path) -> anyhow::Result<Self> {
198        if path.is_dir() {
199            if path.join("config.json").is_file()
200                && (path.join("model.safetensors").is_file()
201                    || path.join("model.safetensors.index.json").is_file()
202                    || std::fs::read_dir(path)?.any(|e| {
203                        e.ok()
204                            .and_then(|e| e.path().extension().map(|x| x == "safetensors"))
205                            .unwrap_or(false)
206                    }))
207            {
208                return Ok(Self::Mlx);
209            }
210            return Err(anyhow::anyhow!(
211                "directory {:?} is not an mlx-community weight dir (need config.json + safetensors)",
212                path
213            ));
214        }
215        match path.extension().and_then(|s| s.to_str()) {
216            Some("safetensors") => Ok(Self::Safetensors),
217            Some("gguf") => Ok(Self::Gguf),
218            Some("npz") | Some("npy") => Ok(Self::Mlx),
219            Some("dduf") => Ok(Self::Dduf),
220            other => Err(anyhow::anyhow!(
221                "cannot autodetect weight format from extension {:?} on {:?}",
222                other,
223                path
224            )),
225        }
226    }
227
228    /// Parse CLI `--format` values (`safetensors` | `gguf` | `mlx` | `dduf`).
229    pub fn parse(s: &str) -> anyhow::Result<Self> {
230        match s {
231            "safetensors" => Ok(Self::Safetensors),
232            "gguf" => Ok(Self::Gguf),
233            "mlx" | "npz" => Ok(Self::Mlx),
234            "dduf" => Ok(Self::Dduf),
235            other => Err(anyhow::anyhow!(
236                "expected safetensors|gguf|mlx|dduf, got {other}"
237            )),
238        }
239    }
240}
241
242/// Where to read a model config from.
243///
244/// Replaces the per-family `Qwen3ConfigSource`, `Llama32ConfigSource`,
245/// `GemmaConfigSource`, `Qwen35ConfigSource` enums.
246#[derive(Debug, Clone, Default)]
247pub enum ConfigSource<T> {
248    /// Read from GGUF metadata.
249    #[default]
250    Embedded,
251    /// Read from a HuggingFace `config.json` at this path.
252    JsonFile(PathBuf),
253    /// Use the supplied config object directly.
254    Explicit(T),
255}
256
257// ─────────────────────────────────────────────────────────────────
258// Sampling
259// ─────────────────────────────────────────────────────────────────
260
261/// Mirostat variant selection. See `crate::samplers::{MirostatV1, MirostatV2}`.
262#[derive(Debug, Default, Clone, Copy, PartialEq)]
263pub enum MirostatMode {
264    #[default]
265    Off,
266    V1,
267    V2,
268}
269
270/// Sampling parameters. Greedy when `temperature == 0` and no advanced
271/// sampler is enabled. All "advanced" knobs default to off / no-op so
272/// legacy callers see classic top-k/top-p/temperature behaviour.
273///
274/// `into_chain()` turns these flat fields into a `SamplerChain` that
275/// downstream backends can execute. Ordering follows llama.cpp's
276/// canonical chain (penalties → temperature → top-k → typical → top-p
277/// → top-n-σ → xtc → mirostat).
278#[derive(Debug, Clone)]
279pub struct SampleOpts {
280    pub temperature: f32,
281    pub top_p: f32,
282    pub top_k: Option<u32>,
283    pub repetition_penalty: f32,
284
285    // ── advanced samplers ────────────────────────────────────────
286    /// Dynamic temperature [min, max] gated by softmax entropy.
287    /// `None` ⇒ flat temperature only.
288    pub dynamic_temp: Option<(f32, f32)>,
289    /// Exponent used by [`crate::samplers::DynamicTemperature`].
290    pub dynamic_temp_exponent: f32,
291    /// Locally-typical sampling (Meister et al. 2022). 1.0 ⇒ off.
292    pub typical_p: f32,
293    /// Top-n-σ cutoff (Hewitt et al. 2024). 0 ⇒ off.
294    pub top_n_sigma: f32,
295    /// Min-p cutoff (Nguyen et al. 2024): keep tokens with prob ≥ `min_p` ·
296    /// p_max. 0 ⇒ off.
297    pub min_p: f32,
298    /// XTC: probability of dropping high-confidence top tokens.
299    pub xtc_threshold: f32,
300    pub xtc_prob: f32,
301    /// DRY repetition penalty knobs.
302    pub dry_multiplier: f32,
303    pub dry_base: f32,
304    pub dry_allowed_length: usize,
305    pub dry_max_ngram: usize,
306    pub dry_sequence_breakers: Vec<u32>,
307    /// Mirostat mode + parameters.
308    pub mirostat: MirostatMode,
309    pub mirostat_tau: f32,
310    pub mirostat_eta: f32,
311    pub mirostat_m: usize,
312    /// Frequency / presence penalties (OpenAI-style).
313    pub frequency_penalty: f32,
314    pub presence_penalty: f32,
315    pub repetition_window: usize,
316    /// Minimum tokens kept by top-p / typical (avoid one-token nucleus).
317    pub min_keep: usize,
318}
319
320impl Default for SampleOpts {
321    fn default() -> Self {
322        Self::greedy()
323    }
324}
325
326impl SampleOpts {
327    pub fn greedy() -> Self {
328        Self {
329            temperature: 0.0,
330            top_p: 1.0,
331            top_k: None,
332            repetition_penalty: 1.0,
333            dynamic_temp: None,
334            dynamic_temp_exponent: 1.0,
335            typical_p: 1.0,
336            top_n_sigma: 0.0,
337            min_p: 0.0,
338            xtc_threshold: 0.0,
339            xtc_prob: 0.0,
340            dry_multiplier: 0.0,
341            dry_base: 1.75,
342            dry_allowed_length: 2,
343            dry_max_ngram: 32,
344            dry_sequence_breakers: Vec::new(),
345            mirostat: MirostatMode::Off,
346            mirostat_tau: 5.0,
347            mirostat_eta: 0.1,
348            mirostat_m: 100,
349            frequency_penalty: 0.0,
350            presence_penalty: 0.0,
351            repetition_window: 64,
352            min_keep: 1,
353        }
354    }
355
356    pub fn nucleus(temperature: f32, top_p: f32) -> Self {
357        Self {
358            temperature,
359            top_p,
360            ..Self::greedy()
361        }
362    }
363
364    pub fn is_greedy(&self) -> bool {
365        self.temperature <= 0.0 && self.mirostat == MirostatMode::Off
366    }
367
368    /// True when only classic top-k/top-p/temperature are configured;
369    /// backends can take a cheap fast path in this case (e.g. the
370    /// existing `sample_row` CPU kernel) instead of building a chain.
371    pub fn is_classic(&self) -> bool {
372        self.dynamic_temp.is_none()
373            && self.typical_p >= 1.0
374            && self.top_n_sigma <= 0.0
375            && self.min_p <= 0.0
376            && self.xtc_prob <= 0.0
377            && self.dry_multiplier <= 0.0
378            && self.mirostat == MirostatMode::Off
379            && self.frequency_penalty == 0.0
380            && self.presence_penalty == 0.0
381            && (self.repetition_penalty - 1.0).abs() < f32::EPSILON
382    }
383
384    /// Build the `SamplerChain` corresponding to these options. The
385    /// returned chain is ready to drive `SamplerChain::sample` against
386    /// a logits row + history. Greedy decoding produces a chain with
387    /// one `Temperature{t:1e-6}` step (which collapses to argmax after
388    /// softmax) — callers that want true greedy can short-circuit via
389    /// `is_greedy()` before building the chain.
390    pub fn into_chain(&self) -> crate::samplers::SamplerChain {
391        use crate::samplers::*;
392        let mut b = SamplerChain::builder();
393
394        // 1. Penalties operate on raw logits, before any temperature
395        //    scaling — matches llama.cpp's order.
396        if (self.repetition_penalty - 1.0).abs() > f32::EPSILON
397            || self.frequency_penalty != 0.0
398            || self.presence_penalty != 0.0
399        {
400            b = b.push(RepetitionPenalty {
401                penalty: self.repetition_penalty,
402                frequency: self.frequency_penalty,
403                presence: self.presence_penalty,
404                last_n: self.repetition_window,
405            });
406        }
407        if self.dry_multiplier > 0.0 {
408            b = b.push(Dry {
409                multiplier: self.dry_multiplier,
410                base: self.dry_base,
411                allowed_length: self.dry_allowed_length,
412                max_ngram: self.dry_max_ngram,
413                sequence_breakers: self.dry_sequence_breakers.clone(),
414            });
415        }
416
417        // 2. Temperature (dynamic or static). Mirostat replaces both.
418        if self.mirostat == MirostatMode::Off {
419            if let Some((mn, mx)) = self.dynamic_temp {
420                b = b.push(DynamicTemperature {
421                    min: mn,
422                    max: mx,
423                    exponent: self.dynamic_temp_exponent,
424                });
425            } else if self.temperature > 0.0 && (self.temperature - 1.0).abs() > f32::EPSILON {
426                b = b.push(Temperature {
427                    t: self.temperature,
428                });
429            } else if self.temperature <= 0.0 {
430                b = b.push(Temperature { t: 1e-6 });
431            }
432        }
433
434        // 3. Filters: top-k → typical → top-p → top-n-sigma → xtc.
435        if let Some(k) = self.top_k {
436            if k > 0 {
437                b = b.push(TopK { k: k as usize });
438            }
439        }
440        if self.typical_p < 1.0 && self.typical_p > 0.0 {
441            b = b.push(TypicalP {
442                p: self.typical_p,
443                min_keep: self.min_keep,
444            });
445        }
446        if self.top_p < 1.0 && self.top_p > 0.0 {
447            b = b.push(TopP {
448                p: self.top_p,
449                min_keep: self.min_keep,
450            });
451        }
452        if self.min_p > 0.0 {
453            b = b.push(MinP {
454                p: self.min_p,
455                min_keep: self.min_keep,
456            });
457        }
458        if self.top_n_sigma > 0.0 {
459            b = b.push(TopNSigma {
460                n: self.top_n_sigma,
461            });
462        }
463        if self.xtc_prob > 0.0 && self.xtc_threshold > 0.0 {
464            b = b.push(Xtc {
465                threshold: self.xtc_threshold,
466                prob: self.xtc_prob,
467                min_keep: self.min_keep,
468            });
469        }
470
471        // 4. Mirostat (replaces softmax+sample at the end of the chain).
472        match self.mirostat {
473            MirostatMode::Off => {}
474            MirostatMode::V1 => {
475                b = b.push(MirostatV1 {
476                    tau: self.mirostat_tau,
477                    eta: self.mirostat_eta,
478                    m: self.mirostat_m,
479                });
480            }
481            MirostatMode::V2 => {
482                b = b.push(MirostatV2 {
483                    tau: self.mirostat_tau,
484                    eta: self.mirostat_eta,
485                });
486            }
487        }
488        b.build()
489    }
490}
491
492// ─────────────────────────────────────────────────────────────────
493// Shared builder
494// ─────────────────────────────────────────────────────────────────
495
496/// Auto-packed threshold: prefer K-quant packed loading for GGUF
497/// files >= this size. Cuts host memory ~6× on Q4_K_M models.
498pub const PACKED_GGUF_AUTO_THRESHOLD_BYTES: u64 = 256 * 1024 * 1024;
499
500/// Builder fields common to every per-family runner.
501///
502/// Per-family runner builders should wrap this and forward the
503/// methods (or use `#[rlx_runner]` from `rlx-macros`).
504#[derive(Debug, Clone)]
505pub struct LmRunnerBuilder<Cfg> {
506    pub weights: Option<PathBuf>,
507    pub config: ConfigSource<Cfg>,
508    pub device: Device,
509    pub max_seq: usize,
510    pub max_memory_gb: Option<f32>,
511    pub stream: bool,
512    pub sample: SampleOpts,
513    pub format: Option<WeightFormat>,
514    /// `None` = auto-detect (packed when GGUF ≥ 256 MB).
515    pub packed_weights: Option<bool>,
516    /// Substring for picking one GGUF in a directory (default `Q4_K_M`).
517    pub prefer_gguf: Option<String>,
518}
519
520impl<Cfg> Default for LmRunnerBuilder<Cfg> {
521    fn default() -> Self {
522        Self {
523            weights: None,
524            config: ConfigSource::Embedded,
525            device: Device::Cpu,
526            max_seq: 128,
527            max_memory_gb: None,
528            stream: true,
529            sample: SampleOpts::greedy(),
530            format: None,
531            packed_weights: None,
532            prefer_gguf: None,
533        }
534    }
535}
536
537impl<Cfg> LmRunnerBuilder<Cfg> {
538    pub fn new() -> Self {
539        Self::default()
540    }
541
542    pub fn weights<P: Into<PathBuf>>(mut self, p: P) -> Self {
543        self.weights = Some(p.into());
544        self
545    }
546
547    pub fn config(mut self, src: ConfigSource<Cfg>) -> Self {
548        self.config = src;
549        self
550    }
551
552    pub fn config_value(self, cfg: Cfg) -> Self {
553        self.config(ConfigSource::Explicit(cfg))
554    }
555
556    pub fn device(mut self, d: Device) -> Self {
557        self.device = d;
558        self
559    }
560
561    pub fn max_seq(mut self, n: usize) -> Self {
562        self.max_seq = n;
563        self
564    }
565
566    pub fn max_memory_gb(mut self, gb: f32) -> Self {
567        self.max_memory_gb = Some(gb);
568        self
569    }
570
571    pub fn stream(mut self, on: bool) -> Self {
572        self.stream = on;
573        self
574    }
575
576    pub fn sample(mut self, s: SampleOpts) -> Self {
577        self.sample = s;
578        self
579    }
580
581    pub fn format(mut self, fmt: WeightFormat) -> Self {
582        self.format = Some(fmt);
583        self
584    }
585
586    pub fn packed_weights(mut self, on: bool) -> Self {
587        self.packed_weights = Some(on);
588        self
589    }
590
591    pub fn prefer_gguf<S: Into<String>>(mut self, q: S) -> Self {
592        self.prefer_gguf = Some(q.into());
593        self
594    }
595
596    /// Resolve the format using the explicit override or the file extension.
597    pub fn resolved_format(&self) -> anyhow::Result<WeightFormat> {
598        match self.format {
599            Some(f) => Ok(f),
600            None => {
601                let p = self
602                    .weights
603                    .as_deref()
604                    .ok_or_else(|| anyhow::anyhow!("weights path required"))?;
605                WeightFormat::from_path(p)
606            }
607        }
608    }
609
610    /// Determine whether packed GGUF loading should be used. Honors an
611    /// explicit override; otherwise auto-enables for GGUF files at or
612    /// above [`PACKED_GGUF_AUTO_THRESHOLD_BYTES`].
613    pub fn resolved_packed(&self, fmt: WeightFormat) -> bool {
614        match self.packed_weights {
615            Some(b) => b,
616            None => {
617                if !matches!(fmt, WeightFormat::Gguf) {
618                    return false;
619                }
620                self.weights
621                    .as_deref()
622                    .and_then(|p| std::fs::metadata(p).ok())
623                    .map(|m| m.len() >= PACKED_GGUF_AUTO_THRESHOLD_BYTES)
624                    .unwrap_or(false)
625            }
626        }
627    }
628}
629
630// ─────────────────────────────────────────────────────────────────
631// Model registry (auto-dispatch by path)
632// ─────────────────────────────────────────────────────────────────
633
634/// Family-routing entry: a short name + a probe closure that returns
635/// `true` for files this family should handle.
636///
637/// Registered at process start by `register_model` (or by a
638/// `#[rlx_runner]`-generated `inventory` entry). [`auto_runner_name`]
639/// walks the registry and returns the first matching family.
640pub struct ModelRegistration {
641    pub family: &'static str,
642    pub description: &'static str,
643    /// `(arch_str_lower_case, path) -> bool`. `arch_str_lower_case` is
644    /// the GGUF `general.architecture` (`""` for safetensors); `path`
645    /// is the concrete weights file. Implementations should return
646    /// `true` if the family owns this file.
647    pub matches: fn(arch: &str, path: &Path) -> bool,
648}
649
650inventory::collect!(ModelRegistration);
651
652/// Re-export of `inventory` so the `register_lm_runner!` proc-macro
653/// can call `::rlx_runtime::lm::inventory::submit!` without forcing
654/// every caller to add `inventory` to their Cargo.toml.
655pub extern crate inventory;
656
657/// Iterate over every registered family.
658pub fn registered_models() -> impl Iterator<Item = &'static ModelRegistration> {
659    inventory::iter::<ModelRegistration>.into_iter()
660}
661
662/// Find the family that claims `(arch, path)`.
663pub fn auto_runner_name(arch: &str, path: &Path) -> Option<&'static str> {
664    let arch_lc = arch.to_ascii_lowercase();
665    registered_models()
666        .find(|m| (m.matches)(&arch_lc, path))
667        .map(|m| m.family)
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn config_source_default_is_embedded() {
676        let s: ConfigSource<()> = ConfigSource::default();
677        assert!(matches!(s, ConfigSource::Embedded));
678    }
679
680    #[test]
681    fn builder_defaults_match_legacy_runners() {
682        let b: LmRunnerBuilder<()> = LmRunnerBuilder::new();
683        assert_eq!(b.device, Device::Cpu);
684        assert_eq!(b.max_seq, 128);
685        assert!(b.stream);
686        assert!(b.sample.is_greedy());
687        assert!(b.packed_weights.is_none());
688    }
689
690    #[test]
691    fn packed_auto_size_threshold() {
692        let mut b: LmRunnerBuilder<()> = LmRunnerBuilder::new();
693        b.weights = Some("/nonexistent/file.gguf".into());
694        // Missing file → auto returns false (no metadata).
695        assert!(!b.resolved_packed(WeightFormat::Gguf));
696        // Explicit override wins.
697        b.packed_weights = Some(true);
698        assert!(b.resolved_packed(WeightFormat::Gguf));
699        // Non-GGUF never auto-packs.
700        b.packed_weights = None;
701        assert!(!b.resolved_packed(WeightFormat::Safetensors));
702    }
703}