Skip to main content

pictor_runtime/
engine.rs

1//! Inference engine orchestrating model loading and generation.
2//!
3//! The [`InferenceEngine`] is the main entry point for running inference.
4//! It owns the model, kernel dispatcher, and sampler, and provides both
5//! blocking ([`InferenceEngine::generate`]) and streaming
6//! ([`InferenceEngine::generate_streaming`]) generation APIs.
7
8use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
9use std::sync::Arc;
10use std::time::Instant;
11
12use pictor_core::config::Qwen3Config;
13use pictor_core::gguf::reader::GgufFile;
14use pictor_kernels::traits::OneBitKernel;
15use pictor_kernels::{KernelDispatcher, KernelTier};
16use pictor_model::model::BonsaiModel;
17
18use crate::batch_engine::{self, BatchResult};
19use crate::error::{RuntimeError, RuntimeResult};
20use crate::metrics::InferenceMetrics;
21#[cfg(all(feature = "metal", target_os = "macos"))]
22use crate::ngram_cache::NgramCache;
23use crate::request_id::RequestId;
24use crate::request_metrics::{RequestRateAggregator, RequestRateSnapshot, RequestRateTracker};
25use crate::sampling::{Sampler, SamplingParams};
26
27/// EOS token for Qwen3 models.
28pub const EOS_TOKEN_ID: u32 = 151645;
29
30/// Statistics about engine usage, accumulated over the engine's lifetime.
31#[derive(Debug)]
32pub struct EngineStats {
33    /// Total number of tokens generated.
34    pub total_tokens_generated: AtomicU64,
35    /// Total number of inference requests completed.
36    pub total_requests: AtomicU64,
37    /// Number of currently active sessions.
38    pub active_sessions: AtomicUsize,
39    /// Engine start time.
40    pub start_time: Instant,
41}
42
43impl EngineStats {
44    /// Create new engine stats, recording the current time as start.
45    pub fn new() -> Self {
46        Self {
47            total_tokens_generated: AtomicU64::new(0),
48            total_requests: AtomicU64::new(0),
49            active_sessions: AtomicUsize::new(0),
50            start_time: Instant::now(),
51        }
52    }
53
54    /// Engine uptime in seconds.
55    pub fn uptime_seconds(&self) -> f64 {
56        self.start_time.elapsed().as_secs_f64()
57    }
58
59    /// Record that a request completed with the given number of generated tokens.
60    pub fn record_request(&self, tokens_generated: usize) {
61        self.total_tokens_generated
62            .fetch_add(tokens_generated as u64, Ordering::Relaxed);
63        self.total_requests.fetch_add(1, Ordering::Relaxed);
64    }
65
66    /// Get total tokens generated.
67    pub fn tokens_generated(&self) -> u64 {
68        self.total_tokens_generated.load(Ordering::Relaxed)
69    }
70
71    /// Get total requests completed.
72    pub fn requests_completed(&self) -> u64 {
73        self.total_requests.load(Ordering::Relaxed)
74    }
75
76    /// Get number of active sessions.
77    pub fn active_session_count(&self) -> usize {
78        self.active_sessions.load(Ordering::Relaxed)
79    }
80
81    /// Average tokens per request (returns 0.0 if no requests).
82    pub fn avg_tokens_per_request(&self) -> f64 {
83        let reqs = self.requests_completed();
84        if reqs == 0 {
85            return 0.0;
86        }
87        self.tokens_generated() as f64 / reqs as f64
88    }
89}
90
91impl Default for EngineStats {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97/// Top-level inference engine.
98pub struct InferenceEngine<'a> {
99    model: BonsaiModel<'a>,
100    kernel: KernelDispatcher,
101    sampler: Sampler,
102    metrics: Option<Arc<InferenceMetrics>>,
103    stats: Arc<EngineStats>,
104    /// Cumulative number of tokens that have been processed by
105    /// [`InferenceEngine::prefill_from_pos`] across the engine's lifetime.
106    ///
107    /// Used by the prefix-cache integration to verify that cached prefixes
108    /// actually reduce prefill work — the cached portion of a prompt is not
109    /// re-fed into prefill, so a repeated prompt should increment this
110    /// counter by strictly fewer tokens than its full length.
111    prefill_token_count: u64,
112    /// Optional workload-level rate aggregator. When attached, every
113    /// `generate_tracked` call records its [`RequestRateSnapshot`] here on
114    /// completion, allowing the operator to surface workload-level p50/p95
115    /// inter-token latency, EWMA tokens-per-second, and queue-wait gauges
116    /// (see [`InferenceMetrics::update_request_rate`]).
117    rate_aggregator: Option<Arc<RequestRateAggregator>>,
118}
119
120impl<'a> InferenceEngine<'a> {
121    /// Create a new inference engine from a configuration (no weights — for testing).
122    pub fn new(config: Qwen3Config, sampling_params: SamplingParams, seed: u64) -> Self {
123        let model = BonsaiModel::new(config);
124        let kernel = KernelDispatcher::auto_detect();
125        let sampler = Sampler::new(sampling_params, seed);
126
127        tracing::info!(kernel = kernel.name(), "inference engine initialized");
128
129        Self {
130            model,
131            kernel,
132            sampler,
133            metrics: None,
134            stats: Arc::new(EngineStats::new()),
135            prefill_token_count: 0,
136            rate_aggregator: None,
137        }
138    }
139
140    /// Wrap an already-constructed [`BonsaiModel`] in an inference engine.
141    ///
142    /// Lets tests (and future custom-model paths) build a model with
143    /// non-trivial weights and then attach the standard sampler/kernel
144    /// machinery without going through the GGUF loader.
145    pub fn from_model(model: BonsaiModel<'a>, sampling_params: SamplingParams, seed: u64) -> Self {
146        Self::from_model_with_kernel(
147            model,
148            KernelDispatcher::auto_detect(),
149            sampling_params,
150            seed,
151        )
152    }
153
154    /// Wrap an already-constructed [`BonsaiModel`] using a caller-supplied
155    /// kernel dispatcher.
156    ///
157    /// Use this when you need to pin the engine to a specific kernel tier
158    /// (e.g. a CPU-only `KernelTier::Reference` for tests that exercise the
159    /// CPU KV-cache path on a host that would otherwise auto-detect a GPU).
160    pub fn from_model_with_kernel(
161        model: BonsaiModel<'a>,
162        kernel: KernelDispatcher,
163        sampling_params: SamplingParams,
164        seed: u64,
165    ) -> Self {
166        let sampler = Sampler::new(sampling_params, seed);
167        Self {
168            model,
169            kernel,
170            sampler,
171            metrics: None,
172            stats: Arc::new(EngineStats::new()),
173            prefill_token_count: 0,
174            rate_aggregator: None,
175        }
176    }
177
178    /// Wrap a [`BonsaiModel`], pinning the engine to a specific [`KernelTier`].
179    ///
180    /// Thin wrapper over [`from_model_with_kernel`](Self::from_model_with_kernel)
181    /// using [`KernelDispatcher::with_tier`]. Used by the cross-backend
182    /// determinism guard to build one engine on `KernelTier::Reference` (scalar
183    /// CPU) and one on `KernelTier::Gpu` (Metal) from the same model bytes and
184    /// assert byte-identical greedy output. `new`/auto-detect are left untouched.
185    pub fn from_model_with_tier(
186        model: BonsaiModel<'a>,
187        tier: KernelTier,
188        sampling_params: SamplingParams,
189        seed: u64,
190    ) -> Self {
191        Self::from_model_with_kernel(
192            model,
193            KernelDispatcher::with_tier(tier),
194            sampling_params,
195            seed,
196        )
197    }
198
199    /// Create a new inference engine from a loaded GGUF file.
200    pub fn from_gguf(
201        gguf: &'a GgufFile<'a>,
202        sampling_params: SamplingParams,
203        seed: u64,
204        max_seq_len: usize,
205    ) -> RuntimeResult<Self> {
206        let model = BonsaiModel::from_gguf(gguf, max_seq_len)?;
207        Self::from_model_with_gpu_warmup(model, sampling_params, seed)
208    }
209
210    /// Create an engine from a loaded GGUF file, reusing a pre-loaded, shared
211    /// token-embedding table.
212    ///
213    /// Identical to [`from_gguf`](Self::from_gguf) except the `token_embd`
214    /// table is supplied by the caller (via
215    /// [`BonsaiModel::from_gguf_with_embd`]) rather than re-dequantized from the
216    /// GGUF. The engine pool uses this to share one `Arc<[f32]>` across all
217    /// replicas (see [`build_pool_from_gguf`](crate::engine_pool::build_pool_from_gguf)).
218    ///
219    /// `token_embd` MUST be the dequantized `token_embd.weight` for this exact
220    /// GGUF; see [`BonsaiModel::from_gguf_with_embd`] for the contract.
221    pub fn from_gguf_with_embd(
222        gguf: &'a GgufFile<'a>,
223        sampling_params: SamplingParams,
224        seed: u64,
225        max_seq_len: usize,
226        token_embd: std::sync::Arc<[f32]>,
227    ) -> RuntimeResult<Self> {
228        let model = BonsaiModel::from_gguf_with_embd(gguf, max_seq_len, token_embd)?;
229        Self::from_model_with_gpu_warmup(model, sampling_params, seed)
230    }
231
232    /// Shared core of [`from_gguf`](Self::from_gguf) and
233    /// [`from_gguf_with_embd`](Self::from_gguf_with_embd): given an
234    /// already-constructed [`BonsaiModel`], auto-detect the kernel, upload
235    /// weights to GPU, run the per-tier warmups, and assemble the engine.
236    ///
237    /// Factored out so the (substantial) GPU/CUDA warmup logic has exactly one
238    /// implementation regardless of how `token_embd` was obtained.
239    fn from_model_with_gpu_warmup(
240        mut model: BonsaiModel<'a>,
241        sampling_params: SamplingParams,
242        seed: u64,
243    ) -> RuntimeResult<Self> {
244        let kernel = KernelDispatcher::auto_detect();
245
246        // Upload all model weights to GPU memory once (no-op on CPU-only tiers).
247        model.upload_weights_to_gpu(&kernel);
248
249        // Pre-build GPU weight cache eagerly so it's outside the timing window.
250        #[cfg(all(feature = "metal", target_os = "macos"))]
251        {
252            tracing::info!("pre-building GPU weight cache");
253            model.get_or_create_gpu_cache().map_err(|e| {
254                RuntimeError::Model(pictor_model::error::ModelError::Internal(format!(
255                    "GPU weight cache init: {e}"
256                )))
257            })?;
258        }
259
260        // Pre-warm both CUDA code paths so all first-call overhead (CUDA driver graph
261        // capture, prefill kernel module loading, weight uploads) is paid during model
262        // loading and NOT inside the benchmark timer.
263        //
264        // Two passes are required:
265        //   1. Single-token decode via `model.forward` → captures the 36-layer CUDA
266        //      driver graph (slow-path ~490ms becomes fast-path ~44ms thereafter).
267        //   2. Two-token batch via `model.forward_prefill` → loads the prefill PTX
268        //      module into GPU driver memory (`init_prefill_modules`) which takes
269        //      ~100-200ms on first call and is not triggered by step 1.
270        //
271        // Both warmup K/V cache writes are at positions that real inference
272        // overwrites immediately (K/V is written before attention reads it).
273        // The CUDA KV cache is separate from the CPU-side `model.kv_cache`.
274        #[cfg(all(
275            feature = "native-cuda",
276            not(all(feature = "metal", target_os = "macos")),
277            any(target_os = "linux", target_os = "windows")
278        ))]
279        {
280            tracing::info!("CUDA warmup: pre-capturing driver graph + prefill modules");
281            // Step 1: capture the 36-layer decode CUDA driver graph.
282            let _ = model.forward(0, 0, &kernel);
283            // Step 2: pre-load the batch-prefill PTX module into GPU driver memory
284            // (`init_prefill_modules`) and pre-allocate the prefill KV cache,
285            // single-token attention buffers, and activation buffers.
286            // We use 17 tokens so the CUDA batch prefill code path is exercised
287            // (prompts ≤ 16 tokens use the fast decode-graph path instead).
288            // This ensures all one-time batch-prefill setup costs are paid before
289            // the benchmark timer, covering longer prompts without a cold-start penalty.
290            let _ = model.forward_prefill(&[0u32; 17], 0, &kernel);
291            tracing::info!("CUDA warmup complete");
292        }
293
294        let sampler = Sampler::new(sampling_params, seed);
295
296        tracing::info!(kernel = kernel.name(), "inference engine loaded from GGUF");
297
298        Ok(Self {
299            model,
300            kernel,
301            sampler,
302            metrics: None,
303            stats: Arc::new(EngineStats::new()),
304            prefill_token_count: 0,
305            rate_aggregator: None,
306        })
307    }
308
309    /// Attach shared metrics to this engine for recording inference telemetry.
310    pub fn set_metrics(&mut self, metrics: Arc<InferenceMetrics>) {
311        self.metrics = Some(metrics);
312    }
313
314    /// Attach a workload-level [`RequestRateAggregator`] to this engine.
315    ///
316    /// Once attached, every call to [`InferenceEngine::generate_tracked`] (or
317    /// [`InferenceEngine::generate_with_request_id`]) will push its
318    /// per-request [`RequestRateSnapshot`] into the aggregator on completion.
319    /// The aggregator is reference-counted, so the same instance can be shared
320    /// with the Prometheus metrics layer or the admin endpoints.
321    pub fn set_rate_aggregator(&mut self, aggregator: Arc<RequestRateAggregator>) {
322        self.rate_aggregator = Some(aggregator);
323    }
324
325    /// Read-only access to the attached rate aggregator, if any.
326    pub fn rate_aggregator(&self) -> Option<&Arc<RequestRateAggregator>> {
327        self.rate_aggregator.as_ref()
328    }
329
330    /// Get a reference to the model.
331    pub fn model(&self) -> &BonsaiModel<'a> {
332        &self.model
333    }
334
335    /// Cheaply clone a handle to this engine's shared token-embedding table.
336    ///
337    /// Thin delegate to [`BonsaiModel::shared_token_embd`]. The engine pool
338    /// calls this on replica `#1` to extract the one shared `Arc<[f32]>`, which
339    /// it then hands to [`InferenceEngine::from_gguf_static_with_embd`] when
340    /// building replicas `2..N` — so every replica's `token_embd` is a clone of
341    /// the same allocation (one ~1.16 GiB table for the 1.7B, not N).
342    pub fn model_token_embd(&self) -> std::sync::Arc<[f32]> {
343        self.model.shared_token_embd()
344    }
345
346    /// Get a mutable reference to the model.
347    ///
348    /// Used by the prefix-cache integration to inject restored KV blocks
349    /// before running the abbreviated prefill.
350    pub fn model_mut(&mut self) -> &mut BonsaiModel<'a> {
351        &mut self.model
352    }
353
354    /// Get a reference to the kernel dispatcher.
355    pub fn kernel(&self) -> &KernelDispatcher {
356        &self.kernel
357    }
358
359    /// Kernel tier this engine dispatches to.
360    ///
361    /// Feature-agnostic convenience used by the engine pool to decide how many
362    /// replicas may safely run in parallel (GPU tiers funnel through a
363    /// process-global singleton and are pinned to a single replica).
364    pub fn kernel_tier(&self) -> KernelTier {
365        self.kernel.tier()
366    }
367
368    /// Run prefill at a given KV-cache offset.
369    ///
370    /// Unlike [`InferenceEngine::generate`], this does **not** reset the
371    /// model's KV cache before execution: callers (e.g. the prefix-cache
372    /// engine) are expected to have prepared the cache state explicitly.
373    ///
374    /// Increments the [`prefill_token_count`](Self::prefill_token_count)
375    /// counter by `prompt_tokens.len()` on success.
376    pub fn prefill_from_pos(
377        &mut self,
378        prompt_tokens: &[u32],
379        pos_start: usize,
380    ) -> RuntimeResult<Vec<f32>> {
381        let logits = self
382            .model
383            .forward_prefill(prompt_tokens, pos_start, &self.kernel)?;
384        self.prefill_token_count = self
385            .prefill_token_count
386            .saturating_add(prompt_tokens.len() as u64);
387        Ok(logits)
388    }
389
390    /// Forward one token at the given absolute position.
391    pub fn decode_step(&mut self, token: u32, pos: usize) -> RuntimeResult<Vec<f32>> {
392        Ok(self.model.forward(token, pos, &self.kernel)?)
393    }
394
395    /// Sample one token from `logits` using the engine's current sampler.
396    pub fn sample(&mut self, logits: &[f32]) -> RuntimeResult<u32> {
397        self.sampler.sample(logits)
398    }
399
400    /// Cumulative number of tokens that have been processed by
401    /// [`InferenceEngine::prefill_from_pos`] over this engine's lifetime.
402    pub fn prefill_token_count(&self) -> u64 {
403        self.prefill_token_count
404    }
405
406    /// Reset the model state for a new conversation.
407    pub fn reset(&mut self) {
408        self.model.reset();
409    }
410
411    /// Get a shared reference to the engine statistics.
412    pub fn stats(&self) -> &Arc<EngineStats> {
413        &self.stats
414    }
415
416    /// Number of currently active sessions (tracked via stats).
417    pub fn active_sessions(&self) -> usize {
418        self.stats.active_session_count()
419    }
420
421    /// Total number of completed requests (tracked via stats).
422    pub fn session_count(&self) -> u64 {
423        self.stats.requests_completed()
424    }
425
426    /// Process a batch of prompts, delegating to [`batch_engine::batch_generate`].
427    ///
428    /// Resets the engine state between each prompt. Returns one result per prompt.
429    pub fn batch_generate(
430        &mut self,
431        prompts: &[Vec<u32>],
432        max_tokens: usize,
433    ) -> Vec<RuntimeResult<BatchResult>> {
434        self.stats.active_sessions.fetch_add(1, Ordering::Relaxed);
435
436        let results = batch_engine::batch_generate(self, prompts, max_tokens);
437
438        // Record stats for successful results
439        for br in results.iter().flatten() {
440            self.stats.record_request(br.generated_tokens.len());
441        }
442
443        self.stats.active_sessions.fetch_sub(1, Ordering::Relaxed);
444
445        results
446    }
447
448    /// Generate tokens from a prompt.
449    ///
450    /// Runs prefill (process the entire prompt), then decodes
451    /// token by token until `max_tokens` or EOS is reached.
452    /// Returns the generated token IDs (not including the prompt).
453    #[tracing::instrument(skip(self, prompt_tokens), fields(prompt_len = prompt_tokens.len()))]
454    pub fn generate(
455        &mut self,
456        prompt_tokens: &[u32],
457        max_tokens: usize,
458    ) -> RuntimeResult<Vec<u32>> {
459        if prompt_tokens.is_empty() {
460            return Ok(vec![]);
461        }
462
463        // ═══════════════════════════════════════════════════════
464        // 1. Prefill: batch process all prompt tokens
465        // ═══════════════════════════════════════════════════════
466        let prefill_start = std::time::Instant::now();
467        let mut last_logits = self.model.forward_prefill(prompt_tokens, 0, &self.kernel)?;
468        if let Some(m) = &self.metrics {
469            m.prefill_duration_seconds
470                .observe(prefill_start.elapsed().as_secs_f64());
471        }
472
473        // ═══════════════════════════════════════════════════════
474        // 2. Decode: sample and generate
475        // ═══════════════════════════════════════════════════════
476        let decode_start = std::time::Instant::now();
477        let mut output_tokens = Vec::with_capacity(max_tokens);
478
479        for (pos, _) in (prompt_tokens.len()..).zip(0..max_tokens) {
480            let step_start = std::time::Instant::now();
481
482            // Sample next token
483            let next_token = self.sampler.sample(&last_logits)?;
484
485            // Check for EOS
486            if next_token == EOS_TOKEN_ID {
487                tracing::debug!(pos, "EOS token generated");
488                break;
489            }
490
491            output_tokens.push(next_token);
492
493            // Forward the generated token
494            last_logits = self.model.forward(next_token, pos, &self.kernel)?;
495
496            if let Some(m) = &self.metrics {
497                m.decode_token_duration_seconds
498                    .observe(step_start.elapsed().as_secs_f64());
499            }
500        }
501
502        // Record tokens/sec and update memory gauge
503        if let Some(m) = &self.metrics {
504            let decode_elapsed = decode_start.elapsed().as_secs_f64();
505            if decode_elapsed > 0.0 && !output_tokens.is_empty() {
506                let tok_per_sec = output_tokens.len() as f64 / decode_elapsed;
507                m.tokens_per_second.observe(tok_per_sec);
508            }
509            m.tokens_generated_total.inc_by(output_tokens.len() as u64);
510            m.update_memory_from_rss();
511        }
512
513        // Record engine-level stats
514        self.stats.record_request(output_tokens.len());
515
516        tracing::info!(
517            prompt_len = prompt_tokens.len(),
518            generated = output_tokens.len(),
519            "generation complete"
520        );
521
522        Ok(output_tokens)
523    }
524
525    /// Generate tokens from a prompt while populating a [`RequestRateTracker`].
526    ///
527    /// Behaves identically to [`InferenceEngine::generate`] but additionally:
528    /// - records `record_admission()` immediately on entry,
529    /// - records `record_first_token()` for the first sampled token,
530    /// - records `record_token()` for every subsequent sampled token,
531    /// - on success, pushes the resulting [`RequestRateSnapshot`] into the
532    ///   engine's attached [`RequestRateAggregator`] (if any).
533    ///
534    /// The tracker is borrowed mutably so callers can inspect intermediate
535    /// state via [`RequestRateTracker::snapshot`] after the call returns.
536    #[tracing::instrument(skip(self, prompt_tokens, tracker), fields(prompt_len = prompt_tokens.len()))]
537    pub fn generate_tracked(
538        &mut self,
539        prompt_tokens: &[u32],
540        max_tokens: usize,
541        tracker: &mut RequestRateTracker,
542    ) -> RuntimeResult<Vec<u32>> {
543        if prompt_tokens.is_empty() {
544            return Ok(vec![]);
545        }
546        tracker.record_admission();
547
548        let prefill_start = std::time::Instant::now();
549        let mut last_logits = self.model.forward_prefill(prompt_tokens, 0, &self.kernel)?;
550        if let Some(m) = &self.metrics {
551            m.prefill_duration_seconds
552                .observe(prefill_start.elapsed().as_secs_f64());
553        }
554
555        let decode_start = std::time::Instant::now();
556        let mut output_tokens = Vec::with_capacity(max_tokens);
557        let mut first_token_recorded = false;
558
559        for (pos, _) in (prompt_tokens.len()..).zip(0..max_tokens) {
560            let step_start = std::time::Instant::now();
561            let next_token = self.sampler.sample(&last_logits)?;
562            if next_token == EOS_TOKEN_ID {
563                tracing::debug!(pos, "EOS token generated");
564                break;
565            }
566            output_tokens.push(next_token);
567            if !first_token_recorded {
568                tracker.record_first_token();
569                first_token_recorded = true;
570            } else {
571                tracker.record_token();
572            }
573            last_logits = self.model.forward(next_token, pos, &self.kernel)?;
574
575            if let Some(m) = &self.metrics {
576                m.decode_token_duration_seconds
577                    .observe(step_start.elapsed().as_secs_f64());
578            }
579        }
580
581        if let Some(m) = &self.metrics {
582            let decode_elapsed = decode_start.elapsed().as_secs_f64();
583            if decode_elapsed > 0.0 && !output_tokens.is_empty() {
584                let tok_per_sec = output_tokens.len() as f64 / decode_elapsed;
585                m.tokens_per_second.observe(tok_per_sec);
586            }
587            m.tokens_generated_total.inc_by(output_tokens.len() as u64);
588            m.update_memory_from_rss();
589        }
590        self.stats.record_request(output_tokens.len());
591
592        if let Some(agg) = &self.rate_aggregator {
593            let snap: RequestRateSnapshot = tracker.snapshot();
594            agg.record(snap);
595        }
596
597        tracing::info!(
598            prompt_len = prompt_tokens.len(),
599            generated = output_tokens.len(),
600            "tracked generation complete"
601        );
602
603        Ok(output_tokens)
604    }
605
606    /// Generate tokens from a prompt with a [`RequestId`] tagging the
607    /// surrounding tracing span and an internally-managed
608    /// [`RequestRateTracker`].
609    ///
610    /// Returns both the generated tokens and the final tracker so callers
611    /// can extract per-request metrics (e.g. queue-wait, p95 inter-token
612    /// latency) for client-side observability.
613    pub fn generate_with_request_id(
614        &mut self,
615        request_id: RequestId,
616        prompt_tokens: &[u32],
617        max_tokens: usize,
618    ) -> RuntimeResult<(Vec<u32>, RequestRateTracker)> {
619        let span = tracing::info_span!("generate_request", request_id = %request_id);
620        let _enter = span.enter();
621        let mut tracker = RequestRateTracker::new();
622        let tokens = self.generate_tracked(prompt_tokens, max_tokens, &mut tracker)?;
623        Ok((tokens, tracker))
624    }
625
626    /// Generate tokens from a prompt using a specific seed for this run.
627    ///
628    /// Temporarily overrides the sampler seed for deterministic multi-completion
629    /// generation (`n > 1`). The sampler state is replaced for the duration of
630    /// this call and then restored.
631    pub fn generate_with_seed(
632        &mut self,
633        prompt_tokens: &[u32],
634        max_tokens: usize,
635        seed: u64,
636        params: &crate::sampling::SamplingParams,
637    ) -> RuntimeResult<Vec<u32>> {
638        // Swap in a fresh sampler with the given seed
639        let old_sampler = std::mem::replace(
640            &mut self.sampler,
641            crate::sampling::Sampler::new(params.clone(), seed),
642        );
643        let result = self.generate(prompt_tokens, max_tokens);
644        // Restore the original sampler
645        self.sampler = old_sampler;
646        result
647    }
648
649    /// Generate tokens from a prompt using caller-supplied sampling parameters
650    /// for the duration of this call only.
651    ///
652    /// Swaps in `params` (temperature, top-k, top-p, repetition penalty) on the
653    /// engine's existing sampler, runs [`InferenceEngine::generate`], then
654    /// restores the previous parameters. Crucially, the sampler's PRNG state is
655    /// **not** reset — only the parameters change — so the RNG sequence for the
656    /// next request is identical to what it would have been had this call used
657    /// the engine's default parameters. This makes the default-parameter case
658    /// bit-identical to calling `generate` directly.
659    pub fn generate_with_params(
660        &mut self,
661        prompt_tokens: &[u32],
662        max_tokens: usize,
663        params: &crate::sampling::SamplingParams,
664    ) -> RuntimeResult<Vec<u32>> {
665        let prev_params = self.sampler.params().clone();
666        self.sampler.set_params(params.clone());
667        let result = self.generate(prompt_tokens, max_tokens);
668        self.sampler.set_params(prev_params);
669        result
670    }
671
672    /// Generate tokens one at a time, sending each through the channel.
673    /// Returns the total count of generated tokens.
674    ///
675    /// Not available on WASM targets (tokio channels not supported on wasm32-unknown-unknown).
676    #[cfg(not(target_arch = "wasm32"))]
677    #[tracing::instrument(skip(self, prompt_tokens, tx), fields(prompt_len = prompt_tokens.len()))]
678    pub fn generate_streaming(
679        &mut self,
680        prompt_tokens: &[u32],
681        max_tokens: usize,
682        tx: &tokio::sync::mpsc::UnboundedSender<u32>,
683    ) -> RuntimeResult<usize> {
684        if prompt_tokens.is_empty() {
685            return Ok(0);
686        }
687
688        // Prefill: batch process all prompt tokens
689        let prefill_start = std::time::Instant::now();
690        let mut logits = self.model.forward_prefill(prompt_tokens, 0, &self.kernel)?;
691        if let Some(m) = &self.metrics {
692            m.prefill_duration_seconds
693                .observe(prefill_start.elapsed().as_secs_f64());
694        }
695
696        let decode_start = std::time::Instant::now();
697        let mut generated = 0;
698
699        for (pos, _) in (prompt_tokens.len()..).zip(0..max_tokens) {
700            let step_start = std::time::Instant::now();
701            let next_token = self.sampler.sample(&logits)?;
702
703            if next_token == EOS_TOKEN_ID {
704                tracing::debug!(pos, "EOS token generated (streaming)");
705                break;
706            }
707
708            // Send token through channel; if receiver dropped, stop generating
709            if tx.send(next_token).is_err() {
710                tracing::debug!(pos, "receiver dropped, stopping generation");
711                break;
712            }
713
714            logits = self.model.forward(next_token, pos, &self.kernel)?;
715            generated += 1;
716
717            if let Some(m) = &self.metrics {
718                m.decode_token_duration_seconds
719                    .observe(step_start.elapsed().as_secs_f64());
720            }
721        }
722
723        // Record tokens/sec and update memory gauge
724        if let Some(m) = &self.metrics {
725            let decode_elapsed = decode_start.elapsed().as_secs_f64();
726            if decode_elapsed > 0.0 && generated > 0 {
727                let tok_per_sec = generated as f64 / decode_elapsed;
728                m.tokens_per_second.observe(tok_per_sec);
729            }
730            m.tokens_generated_total.inc_by(generated as u64);
731            m.update_memory_from_rss();
732        }
733
734        tracing::info!(
735            prompt_len = prompt_tokens.len(),
736            generated,
737            "streaming generation complete"
738        );
739
740        Ok(generated)
741    }
742
743    /// Streaming generation using caller-supplied sampling parameters for the
744    /// duration of this call only.
745    ///
746    /// Swaps in `params` on the engine's existing sampler, runs
747    /// [`InferenceEngine::generate_streaming`], then restores the previous
748    /// parameters. As with [`InferenceEngine::generate_with_params`], the
749    /// sampler's PRNG state is preserved (only the parameters change), so the
750    /// default-parameter case is bit-identical to calling
751    /// `generate_streaming` directly.
752    #[cfg(not(target_arch = "wasm32"))]
753    pub fn generate_streaming_with_params(
754        &mut self,
755        prompt_tokens: &[u32],
756        max_tokens: usize,
757        params: &crate::sampling::SamplingParams,
758        tx: &tokio::sync::mpsc::UnboundedSender<u32>,
759    ) -> RuntimeResult<usize> {
760        let prev_params = self.sampler.params().clone();
761        self.sampler.set_params(params.clone());
762        let result = self.generate_streaming(prompt_tokens, max_tokens, tx);
763        self.sampler.set_params(prev_params);
764        result
765    }
766
767    /// Streaming generation using a synchronous `std::sync::mpsc::Sender`.
768    ///
769    /// Each generated token is sent through the channel immediately, allowing
770    /// the consumer to print tokens as they arrive without requiring a tokio runtime.
771    #[tracing::instrument(skip(self, prompt_tokens, tx), fields(prompt_len = prompt_tokens.len()))]
772    pub fn generate_streaming_sync(
773        &mut self,
774        prompt_tokens: &[u32],
775        max_tokens: usize,
776        tx: &std::sync::mpsc::Sender<u32>,
777    ) -> RuntimeResult<usize> {
778        if prompt_tokens.is_empty() {
779            return Ok(0);
780        }
781
782        // Prefill: batch process all prompt tokens
783        let prefill_start = std::time::Instant::now();
784        let mut logits = self.model.forward_prefill(prompt_tokens, 0, &self.kernel)?;
785        if let Some(m) = &self.metrics {
786            m.prefill_duration_seconds
787                .observe(prefill_start.elapsed().as_secs_f64());
788        }
789
790        let decode_start = std::time::Instant::now();
791        let mut generated = 0;
792
793        for (pos, _) in (prompt_tokens.len()..).zip(0..max_tokens) {
794            let step_start = std::time::Instant::now();
795
796            let next_token = self.sampler.sample(&logits)?;
797
798            if next_token == EOS_TOKEN_ID {
799                tracing::debug!(pos, "EOS token generated (streaming_sync)");
800                break;
801            }
802
803            if tx.send(next_token).is_err() {
804                tracing::debug!(pos, "receiver dropped, stopping generation");
805                break;
806            }
807
808            logits = self.model.forward(next_token, pos, &self.kernel)?;
809            generated += 1;
810
811            if let Some(m) = &self.metrics {
812                m.decode_token_duration_seconds
813                    .observe(step_start.elapsed().as_secs_f64());
814            }
815        }
816
817        if let Some(m) = &self.metrics {
818            let decode_elapsed = decode_start.elapsed().as_secs_f64();
819            if decode_elapsed > 0.0 && generated > 0 {
820                let tok_per_sec = generated as f64 / decode_elapsed;
821                m.tokens_per_second.observe(tok_per_sec);
822            }
823            m.tokens_generated_total.inc_by(generated as u64);
824            m.update_memory_from_rss();
825        }
826
827        tracing::info!(
828            prompt_len = prompt_tokens.len(),
829            generated,
830            "streaming sync generation complete"
831        );
832
833        Ok(generated)
834    }
835
836    /// Greedy generation entirely on GPU (temperature=0, argmax on Metal).
837    ///
838    /// Runs the full forward pass + argmax in a single GPU command buffer per
839    /// token, downloading only the 4-byte token ID instead of the ~607KB logits
840    /// vector. Falls back to the normal `generate` path if the GPU greedy path
841    /// is not available.
842    ///
843    /// Returns the generated token IDs (not including the prompt).
844    #[cfg(all(feature = "metal", target_os = "macos"))]
845    #[tracing::instrument(skip(self, prompt_tokens), fields(prompt_len = prompt_tokens.len()))]
846    pub fn generate_greedy_gpu(
847        &mut self,
848        prompt_tokens: &[u32],
849        max_tokens: usize,
850    ) -> RuntimeResult<Vec<u32>> {
851        if prompt_tokens.is_empty() {
852            return Ok(vec![]);
853        }
854
855        // ═══════════════════════════════════════════════════════
856        // 1. Prefill: batch process all prompt tokens
857        // ═══════════════════════════════════════════════════════
858        let prefill_start = std::time::Instant::now();
859        let last_logits = self.model.forward_prefill(prompt_tokens, 0, &self.kernel)?;
860        if let Some(m) = &self.metrics {
861            m.prefill_duration_seconds
862                .observe(prefill_start.elapsed().as_secs_f64());
863        }
864
865        // First decode token: argmax from prefill logits
866        let first_token = {
867            let mut best_idx = 0u32;
868            let mut best_val = f32::NEG_INFINITY;
869            for (i, &v) in last_logits.iter().enumerate() {
870                if v > best_val {
871                    best_val = v;
872                    best_idx = i as u32;
873                }
874            }
875            best_idx
876        };
877
878        // ═══════════════════════════════════════════════════════
879        // 2. Decode: speculative greedy with n-gram drafting
880        // ═══════════════════════════════════════════════════════
881        let decode_start = std::time::Instant::now();
882        let mut output_tokens = Vec::with_capacity(max_tokens);
883
884        if first_token == EOS_TOKEN_ID {
885            self.stats.record_request(0);
886            return Ok(vec![]);
887        }
888        output_tokens.push(first_token);
889
890        // N-gram cache for zero-cost draft generation
891        let mut ngram_cache = NgramCache::new();
892        ngram_cache.record(prompt_tokens);
893
894        // Running context: prompt + generated tokens (for n-gram lookups)
895        let mut context: Vec<u32> = prompt_tokens.to_vec();
896        context.push(first_token);
897
898        let speculation_k: usize = 4;
899        let mut spec_attempts: u64 = 0;
900        let mut spec_accepted_total: u64 = 0;
901        let spec_enabled = std::env::var("PICTOR_SPEC")
902            .map(|v| v == "1")
903            .unwrap_or(false);
904        let spec_warmup = 15_usize; // build cache before speculating
905
906        let mut next_token = first_token;
907        let mut pos = prompt_tokens.len() + 1;
908        let max_pos = prompt_tokens.len() + max_tokens;
909
910        while pos < max_pos && output_tokens.len() < max_tokens {
911            let step_start = std::time::Instant::now();
912            let tokens_generated = output_tokens.len();
913
914            // Try n-gram draft — skip warmup phase unless explicitly enabled
915            let draft = if !spec_enabled || tokens_generated < spec_warmup {
916                Vec::new()
917            } else {
918                ngram_cache.draft(&context, speculation_k)
919            };
920
921            // Adaptive: only speculate if recent accuracy > 60%
922            // (batch of 5 costs ~4x single token, need high hit rate)
923            let spec_ok = if spec_attempts >= 5 {
924                let accuracy = spec_accepted_total as f64
925                    / (spec_attempts as f64 * speculation_k as f64).max(1.0);
926                accuracy > 0.6 || spec_attempts % 20 == 0
927            } else {
928                true // optimistic for first 5 attempts
929            };
930
931            if !draft.is_empty() && spec_ok {
932                // ── Speculative path: batch verify ──────────────
933                let mut batch = Vec::with_capacity(1 + draft.len());
934                batch.push(next_token);
935                batch.extend_from_slice(&draft);
936
937                match self
938                    .model
939                    .forward_prefill_verify(&batch, pos - 1, &self.kernel)
940                {
941                    Ok(model_preds) => {
942                        spec_attempts += 1;
943
944                        // Verify draft against model predictions
945                        let mut accepted: usize = 0;
946                        for i in 0..draft.len() {
947                            if i < model_preds.len() && draft[i] == model_preds[i] {
948                                accepted += 1;
949                            } else {
950                                break;
951                            }
952                        }
953                        spec_accepted_total += accepted as u64;
954
955                        // Collect accepted draft tokens + bonus
956                        let mut eos_seen = false;
957                        for &token in draft.iter().take(accepted) {
958                            if token == EOS_TOKEN_ID {
959                                eos_seen = true;
960                                break;
961                            }
962                            output_tokens.push(token);
963                            context.push(token);
964                        }
965
966                        if !eos_seen {
967                            // Bonus: model's prediction at the accept/reject boundary
968                            let bonus = if accepted < model_preds.len() {
969                                model_preds[accepted]
970                            } else {
971                                // All draft tokens matched, take the last prediction
972                                match model_preds.last() {
973                                    Some(&tok) => tok,
974                                    None => break,
975                                }
976                            };
977
978                            if bonus == EOS_TOKEN_ID {
979                                tracing::debug!(pos, accepted, "EOS from speculative bonus");
980                                break;
981                            }
982
983                            output_tokens.push(bonus);
984                            context.push(bonus);
985                            next_token = bonus;
986                            pos += accepted + 1;
987
988                            // Update n-gram cache with the newly accepted window
989                            let window_start = context.len().saturating_sub(accepted + 4);
990                            ngram_cache.record(&context[window_start..]);
991                        } else {
992                            tracing::debug!(pos, accepted, "EOS in draft tokens");
993                            break;
994                        }
995                    }
996                    Err(_e) => {
997                        // Speculative verify failed — fall through to single-token decode
998                        tracing::debug!("speculative verify failed, using single-token decode");
999                        match self.model.forward_greedy_gpu(next_token, pos - 1) {
1000                            Ok(token_id) => {
1001                                if token_id == EOS_TOKEN_ID {
1002                                    tracing::debug!(pos, "EOS token generated (greedy GPU)");
1003                                    break;
1004                                }
1005                                output_tokens.push(token_id);
1006                                context.push(token_id);
1007                                let window_start = context.len().saturating_sub(3);
1008                                ngram_cache.record(&context[window_start..]);
1009                                next_token = token_id;
1010                                pos += 1;
1011                            }
1012                            Err(e) => {
1013                                tracing::warn!(
1014                                    error = %e, pos,
1015                                    "greedy GPU path failed, falling back to normal forward"
1016                                );
1017                                let logits =
1018                                    self.model.forward(next_token, pos - 1, &self.kernel)?;
1019                                let mut best_idx = 0u32;
1020                                let mut best_val = f32::NEG_INFINITY;
1021                                for (i, &v) in logits.iter().enumerate() {
1022                                    if v > best_val {
1023                                        best_val = v;
1024                                        best_idx = i as u32;
1025                                    }
1026                                }
1027                                if best_idx == EOS_TOKEN_ID {
1028                                    tracing::debug!(pos, "EOS from CPU fallback");
1029                                    break;
1030                                }
1031                                output_tokens.push(best_idx);
1032                                context.push(best_idx);
1033                                let window_start = context.len().saturating_sub(3);
1034                                ngram_cache.record(&context[window_start..]);
1035                                next_token = best_idx;
1036                                pos += 1;
1037                            }
1038                        }
1039                    }
1040                }
1041            } else {
1042                // ── Single-token decode (no draft or accuracy too low) ──
1043                match self.model.forward_greedy_gpu(next_token, pos - 1) {
1044                    Ok(token_id) => {
1045                        if token_id == EOS_TOKEN_ID {
1046                            tracing::debug!(pos, "EOS token generated (greedy GPU)");
1047                            break;
1048                        }
1049                        output_tokens.push(token_id);
1050                        context.push(token_id);
1051                        let window_start = context.len().saturating_sub(3);
1052                        ngram_cache.record(&context[window_start..]);
1053                        next_token = token_id;
1054                        pos += 1;
1055                    }
1056                    Err(e) => {
1057                        tracing::warn!(
1058                            error = %e, pos,
1059                            "greedy GPU path failed, falling back to normal forward"
1060                        );
1061                        let logits = self.model.forward(next_token, pos - 1, &self.kernel)?;
1062                        let mut best_idx = 0u32;
1063                        let mut best_val = f32::NEG_INFINITY;
1064                        for (i, &v) in logits.iter().enumerate() {
1065                            if v > best_val {
1066                                best_val = v;
1067                                best_idx = i as u32;
1068                            }
1069                        }
1070                        if best_idx == EOS_TOKEN_ID {
1071                            tracing::debug!(pos, "EOS from CPU fallback");
1072                            break;
1073                        }
1074                        output_tokens.push(best_idx);
1075                        context.push(best_idx);
1076                        let window_start = context.len().saturating_sub(3);
1077                        ngram_cache.record(&context[window_start..]);
1078                        next_token = best_idx;
1079                        pos += 1;
1080                    }
1081                }
1082            }
1083
1084            if let Some(m) = &self.metrics {
1085                m.decode_token_duration_seconds
1086                    .observe(step_start.elapsed().as_secs_f64());
1087            }
1088
1089            // Check for EOS from single-token path
1090            if output_tokens.last() == Some(&EOS_TOKEN_ID) {
1091                output_tokens.pop(); // Don't include EOS in output
1092                break;
1093            }
1094        }
1095
1096        // Log speculative decode statistics
1097        if spec_attempts > 0 {
1098            let avg_accepted = spec_accepted_total as f64 / spec_attempts as f64;
1099            let accuracy =
1100                spec_accepted_total as f64 / (spec_attempts as f64 * speculation_k as f64).max(1.0);
1101            tracing::info!(
1102                spec_attempts,
1103                spec_accepted_total,
1104                avg_accepted = format!("{:.2}", avg_accepted),
1105                accuracy = format!("{:.1}%", accuracy * 100.0),
1106                "speculative decode stats"
1107            );
1108        }
1109
1110        // Record tokens/sec and update memory gauge
1111        if let Some(m) = &self.metrics {
1112            let decode_elapsed = decode_start.elapsed().as_secs_f64();
1113            if decode_elapsed > 0.0 && !output_tokens.is_empty() {
1114                let tok_per_sec = output_tokens.len() as f64 / decode_elapsed;
1115                m.tokens_per_second.observe(tok_per_sec);
1116            }
1117            m.tokens_generated_total.inc_by(output_tokens.len() as u64);
1118            m.update_memory_from_rss();
1119        }
1120
1121        self.stats.record_request(output_tokens.len());
1122
1123        tracing::info!(
1124            prompt_len = prompt_tokens.len(),
1125            generated = output_tokens.len(),
1126            "greedy GPU generation complete"
1127        );
1128
1129        Ok(output_tokens)
1130    }
1131}
1132
1133impl InferenceEngine<'static> {
1134    /// Build an engine from an already-`'static` [`GgufFile`].
1135    ///
1136    /// This is the shared core used both by [`from_gguf_path`](Self::from_gguf_path)
1137    /// (after it has leaked the mmap + parsed container to `'static`) and by the
1138    /// engine pool when constructing additional replicas off a single leaked
1139    /// GGUF — every replica borrows the *same* `&'static GgufFile` zero-copy, so
1140    /// only per-replica state (KV cache, light wrappers) is duplicated. The
1141    /// immutable `token_embd` table is shared across replicas via one
1142    /// `Arc<[f32]>` when the pool builder uses
1143    /// [`from_gguf_static_with_embd`](Self::from_gguf_static_with_embd).
1144    ///
1145    /// Performs no leaking itself; the caller owns the `'static` lifetime.
1146    ///
1147    /// # Errors
1148    ///
1149    /// Propagates model-init / GPU-cache errors through [`RuntimeError`].
1150    pub fn from_gguf_static(
1151        gguf: &'static GgufFile<'static>,
1152        sampling_params: SamplingParams,
1153        seed: u64,
1154        max_seq_len: usize,
1155    ) -> RuntimeResult<Self> {
1156        // `from_gguf` is generic over the GGUF borrow lifetime; instantiating it
1157        // at `'static` yields an `InferenceEngine<'static>` directly.
1158        Self::from_gguf(gguf, sampling_params, seed, max_seq_len)
1159    }
1160
1161    /// Build an engine from an already-`'static` [`GgufFile`], reusing a
1162    /// pre-loaded, shared token-embedding table.
1163    ///
1164    /// The `'static`-lifetime twin of
1165    /// [`from_gguf_with_embd`](Self::from_gguf_with_embd). The engine pool calls
1166    /// this for replicas `2..N`, passing the `Arc<[f32]>` extracted from replica
1167    /// `#1` (via [`InferenceEngine::model_token_embd`]) so every replica shares a
1168    /// single token-embedding allocation instead of re-dequantizing its own
1169    /// copy. KV caches and light wrappers remain per-replica.
1170    ///
1171    /// `token_embd` MUST be the dequantized `token_embd.weight` for this exact
1172    /// GGUF; see [`BonsaiModel::from_gguf_with_embd`] for the contract.
1173    pub fn from_gguf_static_with_embd(
1174        gguf: &'static GgufFile<'static>,
1175        sampling_params: SamplingParams,
1176        seed: u64,
1177        max_seq_len: usize,
1178        token_embd: std::sync::Arc<[f32]>,
1179    ) -> RuntimeResult<Self> {
1180        Self::from_gguf_with_embd(gguf, sampling_params, seed, max_seq_len, token_embd)
1181    }
1182
1183    /// Memory-map + parse a GGUF file and leak both allocations to `'static`,
1184    /// returning the constructed engine *and* the leaked `&'static GgufFile`.
1185    ///
1186    /// The leaked reference lets callers (e.g. the engine pool) build additional
1187    /// engine replicas off the *same* weights via [`from_gguf_static`](Self::from_gguf_static)
1188    /// without a second mmap or weight copy. The leaked memory is intentional —
1189    /// the GGUF is expected to live for the process lifetime.
1190    ///
1191    /// # Errors
1192    ///
1193    /// Returns [`RuntimeError::FileNotFound`] if `path` does not exist.  Other
1194    /// IO / parse / model-init errors propagate through [`RuntimeError`].
1195    pub fn from_gguf_path_leaked(
1196        path: impl AsRef<std::path::Path>,
1197        sampling_params: SamplingParams,
1198        seed: u64,
1199        max_seq_len: usize,
1200    ) -> RuntimeResult<(Self, &'static GgufFile<'static>)> {
1201        let path_ref = path.as_ref();
1202        if !path_ref.exists() {
1203            return Err(RuntimeError::FileNotFound {
1204                path: path_ref.display().to_string(),
1205            });
1206        }
1207
1208        // Memory-map and parse, then leak both so the resulting `GgufFile`
1209        // can live for `'static` without RAII concerns.
1210        let mmap = pictor_core::gguf::reader::mmap_gguf_file(path_ref)?;
1211        let mmap: &'static memmap2::Mmap = Box::leak(Box::new(mmap));
1212        let gguf = pictor_core::gguf::reader::GgufFile::parse(mmap)?;
1213        let gguf: &'static GgufFile<'static> = Box::leak(Box::new(gguf));
1214
1215        let engine = Self::from_gguf_static(gguf, sampling_params, seed, max_seq_len)?;
1216        Ok((engine, gguf))
1217    }
1218
1219    /// Load an [`InferenceEngine`] directly from a path to a GGUF file.
1220    ///
1221    /// This is a convenience wrapper intended for server/CLI entry points that
1222    /// need an owned, `'static` engine.  It memory-maps the file, parses the
1223    /// GGUF container, and leaks both allocations so that the borrowed
1224    /// `GgufFile<'a>` lifetime can be promoted to `'static`.
1225    ///
1226    /// The leaked memory is intentional — the engine is expected to live for
1227    /// the process lifetime.  Do not call this in hot-paths.
1228    ///
1229    /// # Errors
1230    ///
1231    /// Returns [`RuntimeError::FileNotFound`] if `path` does not exist.  Other
1232    /// IO / parse / model-init errors propagate through [`RuntimeError`].
1233    pub fn from_gguf_path(
1234        path: impl AsRef<std::path::Path>,
1235        sampling_params: SamplingParams,
1236        seed: u64,
1237        max_seq_len: usize,
1238    ) -> RuntimeResult<Self> {
1239        Self::from_gguf_path_leaked(path, sampling_params, seed, max_seq_len)
1240            .map(|(engine, _gguf)| engine)
1241    }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::*;
1247
1248    #[test]
1249    fn engine_creation() {
1250        let config = Qwen3Config::bonsai_8b();
1251        let engine = InferenceEngine::new(config, SamplingParams::default(), 42);
1252        assert_eq!(engine.model().config().num_layers, 36);
1253    }
1254
1255    #[test]
1256    fn engine_stats_initial() {
1257        let config = Qwen3Config::bonsai_8b();
1258        let engine = InferenceEngine::new(config, SamplingParams::default(), 42);
1259        let stats = engine.stats();
1260        assert_eq!(stats.tokens_generated(), 0);
1261        assert_eq!(stats.requests_completed(), 0);
1262        assert_eq!(stats.active_session_count(), 0);
1263        assert!(stats.uptime_seconds() >= 0.0);
1264        assert!((stats.avg_tokens_per_request() - 0.0).abs() < f64::EPSILON);
1265    }
1266
1267    #[test]
1268    fn engine_stats_record() {
1269        let stats = EngineStats::new();
1270        stats.record_request(10);
1271        stats.record_request(20);
1272        assert_eq!(stats.tokens_generated(), 30);
1273        assert_eq!(stats.requests_completed(), 2);
1274        assert!((stats.avg_tokens_per_request() - 15.0).abs() < f64::EPSILON);
1275    }
1276
1277    #[test]
1278    fn engine_session_tracking() {
1279        let config = Qwen3Config::bonsai_8b();
1280        let engine = InferenceEngine::new(config, SamplingParams::default(), 42);
1281        assert_eq!(engine.active_sessions(), 0);
1282        assert_eq!(engine.session_count(), 0);
1283    }
1284
1285    #[test]
1286    fn engine_batch_generate_empty() {
1287        let config = Qwen3Config::bonsai_8b();
1288        let mut engine = InferenceEngine::new(config, SamplingParams::default(), 42);
1289        let results = engine.batch_generate(&[], 10);
1290        assert!(results.is_empty());
1291        assert_eq!(engine.session_count(), 0);
1292    }
1293
1294    #[test]
1295    fn engine_batch_generate_empty_prompts() {
1296        let config = Qwen3Config::bonsai_8b();
1297        let mut engine = InferenceEngine::new(config, SamplingParams::default(), 42);
1298        let prompts = vec![vec![], vec![]];
1299        let results = engine.batch_generate(&prompts, 5);
1300        assert_eq!(results.len(), 2);
1301        for r in &results {
1302            assert!(r.is_ok());
1303        }
1304        // Stats should reflect the completed requests
1305        assert_eq!(engine.stats().requests_completed(), 2);
1306    }
1307
1308    #[test]
1309    fn engine_stats_default() {
1310        let stats = EngineStats::default();
1311        assert_eq!(stats.tokens_generated(), 0);
1312        assert_eq!(stats.requests_completed(), 0);
1313    }
1314}