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}