Skip to main content

ferrum_engine/
builder.rs

1//! Engine builder with registry-based component creation
2//!
3//! This module provides a fluent builder API for creating inference engines
4//! using the component registry pattern. The builder supports:
5//!
6//! - Configuration-driven component selection
7//! - Custom component overrides
8//! - Automatic fallback to defaults
9//! - Validation before engine creation
10
11use crate::registry::{ComponentConfig, ComponentRegistry};
12use ferrum_interfaces::engine::{InferenceEngine, LlmInferenceEngine};
13use ferrum_interfaces::{
14    KvCacheManager, ModelExecutor, RecurrentStateManager, Sampler, SchedulerInterface as Scheduler,
15    TensorFactory, Tokenizer,
16};
17use ferrum_models::vnext::{DefinedProductionModel, ProductionModelSourceBundle};
18use ferrum_types::{EngineConfig, FerrumError, Result};
19use std::sync::Arc;
20use tracing::{debug, info};
21
22// Engine-build composition knobs (FERRUM_MODEL_PATH / FERRUM_SPEC_DRAFT /
23// FERRUM_SPEC_N) are no longer read from the environment here. The CLI
24// composition root captures them via `RuntimeConfigSnapshot::capture_current()`
25// and lands them in `EngineConfig.runtime` through
26// `apply_runtime_config_snapshot`; the builder reads `self.config.runtime`.
27
28/// Engine builder for creating inference engines with registry-based components
29pub struct EngineBuilder {
30    /// Component registry to use
31    registry: Arc<ComponentRegistry>,
32    /// Engine configuration
33    config: EngineConfig,
34    /// Product-resolved semantic, tokenizer, and weight sources.
35    model_sources: Option<Arc<ProductionModelSourceBundle>>,
36    /// Immutable typed model package prepared once by the product composition
37    /// root and reused by startup policy and executor construction.
38    defined_model: Option<Arc<DefinedProductionModel>>,
39    /// Override: custom tokenizer name
40    tokenizer_name: Option<String>,
41    /// Override: custom sampler name
42    sampler_name: Option<String>,
43    /// Override: custom scheduler name
44    scheduler_name: Option<String>,
45    /// Override: custom KV cache name
46    kv_cache_name: Option<String>,
47    /// Override: custom executor name
48    executor_name: Option<String>,
49    /// Pre-created tokenizer (skip factory)
50    custom_tokenizer: Option<Arc<dyn Tokenizer + Send + Sync>>,
51    /// Pre-created sampler (skip factory)
52    custom_sampler: Option<Arc<dyn Sampler + Send + Sync>>,
53    /// Pre-created scheduler (skip factory)
54    custom_scheduler: Option<Arc<dyn Scheduler + Send + Sync>>,
55    /// Pre-created KV cache (skip factory)
56    custom_kv_cache: Option<Arc<dyn KvCacheManager + Send + Sync>>,
57    /// Pre-created recurrent-state manager (skip factory)
58    custom_recurrent_state_manager: Option<Arc<dyn RecurrentStateManager + Send + Sync>>,
59    /// Pre-created executor (skip factory)
60    custom_executor: Option<Arc<dyn ModelExecutor + Send + Sync>>,
61}
62
63impl EngineBuilder {
64    /// Create a new engine builder with default registry
65    pub fn new(config: EngineConfig) -> Self {
66        Self::with_registry(config, crate::registry::global_registry())
67    }
68
69    /// Create a new engine builder with a custom registry
70    pub fn with_registry(config: EngineConfig, registry: Arc<ComponentRegistry>) -> Self {
71        Self {
72            registry,
73            config,
74            model_sources: None,
75            defined_model: None,
76            tokenizer_name: None,
77            sampler_name: None,
78            scheduler_name: None,
79            kv_cache_name: None,
80            executor_name: None,
81            custom_tokenizer: None,
82            custom_sampler: None,
83            custom_scheduler: None,
84            custom_kv_cache: None,
85            custom_recurrent_state_manager: None,
86            custom_executor: None,
87        }
88    }
89
90    pub fn with_model_sources(mut self, sources: Arc<ProductionModelSourceBundle>) -> Self {
91        self.model_sources = Some(sources);
92        self.defined_model = None;
93        self
94    }
95
96    pub fn with_defined_model(mut self, prepared: Arc<DefinedProductionModel>) -> Self {
97        self.model_sources = Some(Arc::clone(prepared.sources()));
98        self.defined_model = Some(prepared);
99        self
100    }
101
102    /// Set the tokenizer to use by name
103    pub fn with_tokenizer(mut self, name: impl Into<String>) -> Self {
104        self.tokenizer_name = Some(name.into());
105        self
106    }
107
108    /// Set a pre-created tokenizer
109    pub fn with_custom_tokenizer(mut self, tokenizer: Arc<dyn Tokenizer + Send + Sync>) -> Self {
110        self.custom_tokenizer = Some(tokenizer);
111        self
112    }
113
114    /// Set the sampler to use by name
115    pub fn with_sampler(mut self, name: impl Into<String>) -> Self {
116        self.sampler_name = Some(name.into());
117        self
118    }
119
120    /// Set a pre-created sampler
121    pub fn with_custom_sampler(mut self, sampler: Arc<dyn Sampler + Send + Sync>) -> Self {
122        self.custom_sampler = Some(sampler);
123        self
124    }
125
126    /// Set the scheduler to use by name
127    pub fn with_scheduler(mut self, name: impl Into<String>) -> Self {
128        self.scheduler_name = Some(name.into());
129        self
130    }
131
132    /// Set a pre-created scheduler
133    pub fn with_custom_scheduler(mut self, scheduler: Arc<dyn Scheduler + Send + Sync>) -> Self {
134        self.custom_scheduler = Some(scheduler);
135        self
136    }
137
138    /// Set the KV cache to use by name
139    pub fn with_kv_cache(mut self, name: impl Into<String>) -> Self {
140        self.kv_cache_name = Some(name.into());
141        self
142    }
143
144    /// Set a pre-created KV cache manager
145    pub fn with_custom_kv_cache(mut self, kv_cache: Arc<dyn KvCacheManager + Send + Sync>) -> Self {
146        self.custom_kv_cache = Some(kv_cache);
147        self
148    }
149
150    /// Set a pre-created recurrent-state manager.
151    pub fn with_custom_recurrent_state_manager(
152        mut self,
153        manager: Arc<dyn RecurrentStateManager + Send + Sync>,
154    ) -> Self {
155        self.custom_recurrent_state_manager = Some(manager);
156        self
157    }
158
159    /// Set the executor to use by name
160    pub fn with_executor(mut self, name: impl Into<String>) -> Self {
161        self.executor_name = Some(name.into());
162        self
163    }
164
165    /// Set a pre-created model executor
166    pub fn with_custom_executor(mut self, executor: Arc<dyn ModelExecutor + Send + Sync>) -> Self {
167        self.custom_executor = Some(executor);
168        self
169    }
170
171    /// Determine which tokenizer to use based on config and overrides
172    fn resolve_tokenizer_name(&self) -> String {
173        if let Some(ref name) = self.tokenizer_name {
174            return name.clone();
175        }
176
177        // If model path is set, try huggingface first
178        if self.has_typed_model_path() || self.config.runtime.model_path.is_some() {
179            return "huggingface".to_string();
180        }
181
182        "stub".to_string()
183    }
184
185    /// Determine which sampler to use based on config and overrides
186    fn resolve_sampler_name(&self) -> String {
187        if let Some(ref name) = self.sampler_name {
188            return name.clone();
189        }
190
191        // Could derive from sampling params in the future
192        "multinomial".to_string()
193    }
194
195    /// Determine which KV cache to use based on config and overrides
196    fn resolve_kv_cache_name(&self) -> String {
197        if let Some(ref name) = self.kv_cache_name {
198            return name.clone();
199        }
200
201        match self.config.kv_cache.cache_type {
202            ferrum_types::KvCacheType::Contiguous => "default".to_string(),
203            ferrum_types::KvCacheType::Paged => "paged".to_string(),
204            ferrum_types::KvCacheType::Tree => "default".to_string(), // Fallback
205        }
206    }
207
208    /// Determine which executor to use based on config and overrides
209    fn resolve_executor_name(&self) -> String {
210        if let Some(ref name) = self.executor_name {
211            return name.clone();
212        }
213
214        // If model path is set, try candle executor
215        if self.has_typed_model_path() || self.config.runtime.model_path.is_some() {
216            return "llm".to_string();
217        }
218
219        "stub".to_string()
220    }
221
222    fn has_typed_model_path(&self) -> bool {
223        self.model_sources.is_some()
224            || self
225                .config
226                .backend
227                .backend_options
228                .get("model_path")
229                .and_then(|value| value.as_str())
230                .is_some()
231    }
232
233    /// Build the inference engine
234    pub async fn build(self) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
235        info!(
236            "Building inference engine for model: {}",
237            self.config.model.model_id
238        );
239
240        if self.scheduler_name.is_some() || self.custom_scheduler.is_some() {
241            return Err(FerrumError::config(
242                "EngineBuilder scheduler component overrides are no longer accepted; configure the typed EngineConfig.scheduler used by ContinuousBatchScheduler",
243            ));
244        }
245
246        // Pre-compute all component names before consuming self
247        let tokenizer_name = self.resolve_tokenizer_name();
248        let sampler_name = self.resolve_sampler_name();
249        let kv_cache_name = self.resolve_kv_cache_name();
250        let executor_name = self.resolve_executor_name();
251        let explicit_kv_cache_override = self.kv_cache_name.is_some();
252
253        let component_config = ComponentConfig::from_engine_config_and_product_model(
254            &self.config,
255            self.model_sources.clone(),
256            self.defined_model.clone(),
257        );
258        validate_layer_split_plan(&component_config)?;
259        let typed_model_path = component_config.get_string_option("model_path");
260        let has_model_path = typed_model_path.is_some() || self.config.runtime.model_path.is_some();
261        let registry = self.registry.clone();
262        let mut config = self.config;
263
264        // Extract custom components. Phase 3e+ deleted the legacy
265        // `ComputeBackend` trait, so there's no "backend" component to
266        // build here — real GPU dispatch goes through `Backend<B>` in
267        // `ferrum-kernels`. The stub executor now wires
268        // `CandleTensorFactory` directly from the registry.
269        let custom_tokenizer = self.custom_tokenizer;
270        let custom_sampler = self.custom_sampler;
271        let custom_kv_cache = self.custom_kv_cache;
272        let custom_recurrent_state_manager = self.custom_recurrent_state_manager;
273        let custom_executor = self.custom_executor;
274
275        // 2. Create or use provided tokenizer
276        let tokenizer = if let Some(tokenizer) = custom_tokenizer {
277            debug!("Using custom tokenizer");
278            tokenizer
279        } else {
280            debug!("Creating tokenizer: {}", tokenizer_name);
281
282            // Try primary tokenizer, fallback to stub
283            match registry
284                .create_tokenizer(&tokenizer_name, &component_config)
285                .await
286            {
287                Ok(t) => t,
288                Err(e) => {
289                    if has_model_path {
290                        return Err(FerrumError::config(format!(
291                            "Failed to create tokenizer '{}' in model mode: {}",
292                            tokenizer_name, e
293                        )));
294                    }
295
296                    tracing::warn!(
297                        "Failed to create tokenizer '{}': {}, falling back to stub",
298                        tokenizer_name,
299                        e
300                    );
301                    registry.create_tokenizer("stub", &component_config).await?
302                }
303            }
304        };
305
306        // 3. Create or use provided sampler
307        let sampler = if let Some(sampler) = custom_sampler {
308            debug!("Using custom sampler");
309            sampler
310        } else {
311            debug!("Creating sampler: {}", sampler_name);
312            registry
313                .create_sampler(&sampler_name, &component_config)
314                .await?
315        };
316
317        // 4. Resolve the executor before resource managers. Its typed
318        // authority decides whether engine-side KV/recurrent managers may
319        // exist at all.
320        let executor = if let Some(executor) = custom_executor {
321            debug!("Using custom executor");
322            executor
323        } else {
324            debug!("Creating executor: {}", executor_name);
325
326            // Try primary executor, fallback to stub
327            match registry
328                .create_executor(&executor_name, &component_config)
329                .await
330            {
331                Ok(e) => e,
332                Err(err) => {
333                    if has_model_path {
334                        return Err(FerrumError::config(format!(
335                            "Failed to create executor '{}' in model mode: {}",
336                            executor_name, err
337                        )));
338                    }
339
340                    tracing::warn!(
341                        "Failed to create executor '{}': {}, falling back to stub",
342                        executor_name,
343                        err
344                    );
345                    registry.create_executor("stub", &component_config).await?
346                }
347            }
348        };
349        if let Some(plan) = executor.startup_memory_plan() {
350            plan.apply_to_engine_config(&mut config)
351                .map_err(FerrumError::config)?;
352        }
353        let execution_resource_authority = executor.execution_resource_authority();
354
355        let (kv_cache, recurrent_state_manager) = match execution_resource_authority {
356            ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime => {
357                if explicit_kv_cache_override || custom_kv_cache.is_some() {
358                    return Err(FerrumError::config(
359                        "plan runtime cannot be combined with a legacy engine KV-cache override",
360                    ));
361                }
362                if custom_recurrent_state_manager.is_some() {
363                    return Err(FerrumError::config(
364                        "plan runtime cannot be combined with a legacy engine recurrent-state manager",
365                    ));
366                }
367                if executor.resolved_model_plan().is_none() {
368                    return Err(FerrumError::config(
369                        "plan-runtime executor did not expose its authoritative ResolvedModelPlan",
370                    ));
371                }
372                (None, None)
373            }
374            ferrum_interfaces::model_executor::ExecutionResourceAuthority::LegacyEngine => {
375                let kv_cache = if let Some(kv_cache) = custom_kv_cache {
376                    debug!("Using custom KV cache");
377                    kv_cache
378                } else {
379                    debug!("Creating KV cache: {}", kv_cache_name);
380                    registry
381                        .create_kv_cache(&kv_cache_name, &component_config)
382                        .await?
383                };
384                let recurrent_state_manager = custom_recurrent_state_manager
385                    .or_else(|| default_recurrent_state_manager(&config));
386                (Some(kv_cache), recurrent_state_manager)
387            }
388        };
389
390        // 5. Create the engine — always ContinuousBatchEngine.
391        info!("All components created, building ContinuousBatchEngine");
392
393        let cb_scheduler = Arc::new(
394            ferrum_scheduler::implementations::ContinuousBatchScheduler::new(
395                config.scheduler.clone(),
396            ),
397        );
398
399        // Create TensorFactory for the configured device
400        let tensor_factory: Arc<dyn TensorFactory> = Arc::new(
401            crate::tensor_factory::candle::CandleTensorFactory::new(config.backend.device.clone()),
402        );
403
404        // Opt-in speculative decoding: provide an absolute HF snapshot path
405        // for a second smaller model. The draft must use the same tokenizer
406        // + vocab as the target (same family e.g. Qwen3). Backend options are
407        // the typed startup path; the legacy speculative env names remain
408        // compatibility aliases.
409        let spec_draft = component_config
410            .get_string_option("spec_draft")
411            .or_else(|| config.runtime.spec_draft.clone());
412        if execution_resource_authority
413            == ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime
414            && spec_draft.is_some()
415        {
416            return Err(FerrumError::unsupported(
417                "speculative decoding is not yet part of the plan-runtime contract",
418            ));
419        }
420        let spec_n = component_config
421            .get_option::<usize>("spec_n")
422            .unwrap_or(config.runtime.spec_n.unwrap_or(4));
423        let (draft_executor, spec_config) = match spec_draft.as_ref() {
424            Some(draft_path) => {
425                info!("Speculative decoding: loading draft model from {draft_path}");
426                let mut draft_cfg = component_config.clone();
427                draft_cfg.component_options.insert(
428                    "model_path".to_string(),
429                    serde_json::Value::String(draft_path.to_string()),
430                );
431                let draft = registry
432                    .create_executor(&executor_name, &draft_cfg)
433                    .await
434                    .map_err(|error| {
435                        FerrumError::config(format!(
436                            "requested speculative draft executor failed to load: {error}"
437                        ))
438                    })?;
439                if draft.execution_resource_authority() != execution_resource_authority {
440                    return Err(FerrumError::config(
441                        "target and speculative draft executors declare different resource authority",
442                    ));
443                }
444                (
445                    Some(draft),
446                    Some(crate::speculative::SpeculativeDecodingConfig {
447                        num_speculative_tokens: spec_n,
448                        temperature: 1.0,
449                    }),
450                )
451            }
452            _ => (None, None),
453        };
454
455        // Construct the unpublished engine shell first so its typed profile
456        // sink is attached before executor startup emits warmup/capture events.
457        let engine = match execution_resource_authority {
458            ferrum_interfaces::model_executor::ExecutionResourceAuthority::PlanRuntime => {
459                crate::ContinuousBatchEngine::new_plan_runtime(
460                    config,
461                    cb_scheduler,
462                    tokenizer,
463                    sampler,
464                    Arc::clone(&executor),
465                    tensor_factory,
466                )?
467            }
468            ferrum_interfaces::model_executor::ExecutionResourceAuthority::LegacyEngine => {
469                let kv_cache = kv_cache.ok_or_else(|| {
470                    FerrumError::internal("legacy-engine composition lost its KV-cache manager")
471                })?;
472                crate::ContinuousBatchEngine::new_with_speculation_and_recurrent_state_manager(
473                    config,
474                    cb_scheduler,
475                    tokenizer,
476                    sampler,
477                    kv_cache,
478                    Arc::clone(&executor),
479                    tensor_factory,
480                    draft_executor.clone(),
481                    spec_config,
482                    recurrent_state_manager,
483                )?
484            }
485        };
486
487        // This is the single product readiness boundary shared by `run` and
488        // `serve`. The engine is not exposed until executor-owned compilation
489        // and warmup complete, but those events now share the product trace.
490        let startup_result = async {
491            executor.prepare_startup().await?;
492            if let Some(draft) = draft_executor.as_ref() {
493                draft.prepare_startup().await?;
494            }
495            Ok(())
496        }
497        .await;
498        if let Err(startup_error) = startup_result {
499            if let Err(shutdown_error) = engine.shutdown().await {
500                tracing::warn!(
501                    "Failed to close engine resources after startup rejection: {shutdown_error}"
502                );
503            }
504            return Err(startup_error);
505        }
506        Ok(Box::new(engine))
507    }
508}
509
510fn default_recurrent_state_manager(
511    config: &EngineConfig,
512) -> Option<Arc<dyn RecurrentStateManager + Send + Sync>> {
513    let total_batch_slots = config
514        .runtime
515        .recurrent_state_max_slots
516        .unwrap_or(usize::MAX);
517    Some(
518        Arc::new(crate::recurrent_state::InMemoryRecurrentStateManager::new(
519            crate::recurrent_state::InMemoryRecurrentStateConfig {
520                total_memory_bytes: usize::MAX,
521                total_batch_slots,
522            },
523        )) as Arc<dyn RecurrentStateManager + Send + Sync>,
524    )
525}
526
527fn validate_layer_split_plan(component_config: &ComponentConfig) -> Result<()> {
528    if component_config
529        .get_string_option("selected_distributed_strategy")
530        .as_deref()
531        != Some("layer_split")
532    {
533        return Ok(());
534    }
535    let requested = component_config
536        .get_option::<Vec<usize>>("requested_gpu_devices")
537        .unwrap_or_default();
538    let selected = component_config
539        .get_option::<Vec<usize>>("selected_gpu_devices")
540        .unwrap_or_default();
541    let plan_raw = component_config.get_string_option("selected_layer_split_plan");
542    let parsed_plan = if let Some(stages) = component_config
543        .component_options
544        .get("selected_layer_split_stages")
545    {
546        crate::layer_split::parse_layer_split_stage_documents(stages)?
547    } else {
548        let plan_raw = plan_raw.as_deref().ok_or_else(|| {
549            FerrumError::config(
550                "selected_distributed_strategy=layer_split requires selected_layer_split_plan",
551            )
552        })?;
553        crate::layer_split::parse_layer_split_plan(plan_raw)?
554    };
555    crate::layer_split::validate_layer_split_plan_for_devices(&parsed_plan, &selected)?;
556    let execution_plan = parsed_plan.to_execution_plan();
557    let stage_ranges = execution_plan
558        .layer_distribution
559        .stage_layers
560        .iter()
561        .map(|range| format!("{}-{}", range.start, range.end.saturating_sub(1)))
562        .collect::<Vec<_>>()
563        .join(",");
564    let plan_label = plan_raw.unwrap_or_else(|| format!("{:?}", parsed_plan.stages));
565    tracing::info!(
566        "validated CUDA layer_split plan: requested_gpu_devices={requested:?} selected_gpu_devices={selected:?} selected_layer_split_plan={plan_label} total_layers={} pipeline_stages={} stage_ranges={stage_ranges} communication_backend={}",
567        parsed_plan.total_layers(),
568        execution_plan.parallel_config.pipeline_parallel_size,
569        execution_plan.parallel_config.communication_backend,
570    );
571    Ok(())
572}
573
574/// Create an engine with the default configuration and registry
575pub async fn create_engine(
576    config: EngineConfig,
577) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
578    EngineBuilder::new(config).build().await
579}
580
581/// Create a product engine from one immutable role-specific source bundle.
582pub async fn create_product_engine(
583    config: EngineConfig,
584    sources: Arc<ProductionModelSourceBundle>,
585) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
586    EngineBuilder::new(config)
587        .with_model_sources(sources)
588        .build()
589        .await
590}
591
592/// Create a product engine from the exact typed model package already used by
593/// startup capability and resource-policy resolution.
594pub async fn create_defined_product_engine(
595    config: EngineConfig,
596    prepared: Arc<DefinedProductionModel>,
597) -> Result<Box<dyn LlmInferenceEngine + Send + Sync>> {
598    EngineBuilder::new(config)
599        .with_defined_model(prepared)
600        .build()
601        .await
602}
603
604// ============================================================================
605// Tests
606// ============================================================================
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use ferrum_interfaces::{
612        model_executor::{
613            DecodeInput, DecodeOutput, ExecutionResourceAuthority, ExecutorCapabilities,
614            ExecutorStatus, PlanRuntimeResourceSnapshot, PrefillInput, PrefillOutput,
615        },
616        vnext::ExecutionEventSink,
617        RecurrentStateHandle, RecurrentStateManager, RecurrentStateManagerStats,
618        RecurrentStateSpec, RecurrentStateTensorSpec,
619    };
620    use ferrum_types::{DataType, Device, RequestId};
621    use std::sync::{
622        atomic::{AtomicBool, AtomicUsize, Ordering},
623        Mutex,
624    };
625
626    #[derive(Debug)]
627    struct NoopRecurrentStateManager;
628
629    struct PlanRuntimeBuilderExecutor {
630        inner: ferrum_testkit::MockModelExecutor,
631    }
632
633    struct StartupProbeExecutor {
634        inner: ferrum_testkit::MockModelExecutor,
635        calls: Arc<AtomicUsize>,
636        fail: bool,
637    }
638
639    struct ProfileStartupProbeExecutor {
640        inner: ferrum_testkit::MockModelExecutor,
641        event_sink: Mutex<Option<Arc<dyn ExecutionEventSink>>>,
642        saw_sink_during_startup: Arc<AtomicBool>,
643    }
644
645    #[async_trait::async_trait]
646    impl ModelExecutor for StartupProbeExecutor {
647        fn info(&self) -> &ferrum_types::ModelInfo {
648            self.inner.info()
649        }
650
651        async fn prepare_startup(&self) -> Result<()> {
652            self.calls.fetch_add(1, Ordering::Relaxed);
653            if self.fail {
654                return Err(FerrumError::backend("startup preparation rejected"));
655            }
656            Ok(())
657        }
658
659        async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
660            self.inner.prefill(input).await
661        }
662
663        async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
664            self.inner.decode(input).await
665        }
666
667        fn capabilities(&self) -> ExecutorCapabilities {
668            self.inner.capabilities()
669        }
670
671        fn status(&self) -> ExecutorStatus {
672            self.inner.status()
673        }
674    }
675
676    #[async_trait::async_trait]
677    impl ModelExecutor for ProfileStartupProbeExecutor {
678        fn info(&self) -> &ferrum_types::ModelInfo {
679            self.inner.info()
680        }
681
682        async fn prepare_startup(&self) -> Result<()> {
683            use ferrum_interfaces::vnext::{ExecutionEventEmitter, TrustedExecutionEventContext};
684
685            let sink = self
686                .event_sink
687                .lock()
688                .expect("profile startup probe sink lock")
689                .clone();
690            self.saw_sink_during_startup
691                .store(sink.is_some(), Ordering::Release);
692            let Some(sink) = sink else {
693                return Ok(());
694            };
695            let (run_id, request_id, event) = startup_profile_test_event();
696            ExecutionEventEmitter::from_shared(sink, run_id.clone(), request_id.clone())
697                .emit(
698                    event,
699                    &TrustedExecutionEventContext::pre_plan(&run_id, &request_id),
700                )
701                .map_err(|error| {
702                    FerrumError::internal(format!("emit startup profile probe: {error}"))
703                })
704        }
705
706        fn attach_execution_event_sink(&self, sink: Arc<dyn ExecutionEventSink>) {
707            *self
708                .event_sink
709                .lock()
710                .expect("profile startup probe sink lock") = Some(sink);
711        }
712
713        async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
714            self.inner.prefill(input).await
715        }
716
717        async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
718            self.inner.decode(input).await
719        }
720
721        fn capabilities(&self) -> ExecutorCapabilities {
722            self.inner.capabilities()
723        }
724
725        fn status(&self) -> ExecutorStatus {
726            self.inner.status()
727        }
728    }
729
730    fn startup_profile_test_event() -> (
731        ferrum_interfaces::vnext::RunId,
732        ferrum_interfaces::vnext::RequestIdentity,
733        ferrum_interfaces::vnext::ExecutionEvent,
734    ) {
735        use ferrum_interfaces::vnext::{
736            ExecutionEvent, ExecutionEventDetail, ExecutionEventKind, ExecutionIdentityEnvelope,
737            ExecutionIdentityParts, ExecutionPhase, MonotonicTimestamp, RequestIdentity, RunId,
738            SpanId, EXECUTION_IDENTITY_VERSION,
739        };
740
741        let run_id = RunId::new("run.vnext.builder-startup-profile").unwrap();
742        let request_id = RequestIdentity::new("request.vnext.builder-startup-profile").unwrap();
743        let event = ExecutionEvent::new(
744            MonotonicTimestamp {
745                nanos_since_run_start: 1,
746            },
747            ExecutionPhase::Resolution,
748            ExecutionEventKind::RequestAccepted,
749            ExecutionIdentityEnvelope::new(ExecutionIdentityParts {
750                version: EXECUTION_IDENTITY_VERSION,
751                run_id: run_id.clone(),
752                request_id: request_id.clone(),
753                sequence: 1,
754                plan_id: None,
755                plan_hash: None,
756                frame_id: None,
757                node_invocation_id: None,
758                node_id: None,
759                operation_id: None,
760                provider_id: None,
761                device_id: None,
762                resource_pool_id: None,
763                resource_pool_identity_fingerprint: None,
764                provisioning_run_id: None,
765                provisioning_request_id: None,
766                transaction_id: None,
767                active_sequence_slot: None,
768                admission_generation: None,
769                activation_epoch: None,
770                runtime_implementation_fingerprint: None,
771                active_sequence_fingerprint: None,
772                completed_sequence_fingerprint: None,
773                aborted_sequence_fingerprint: None,
774                resource_id: None,
775                resource_generation: None,
776                resource_batch_fingerprint: None,
777                span_id: SpanId::new("vnext/request/builder-startup-profile").unwrap(),
778                parent_span_id: None,
779                async_links: Vec::new(),
780            })
781            .unwrap(),
782            ExecutionEventDetail::None,
783        )
784        .unwrap();
785        (run_id, request_id, event)
786    }
787
788    #[async_trait::async_trait]
789    impl ModelExecutor for PlanRuntimeBuilderExecutor {
790        fn info(&self) -> &ferrum_types::ModelInfo {
791            self.inner.info()
792        }
793
794        fn execution_resource_authority(&self) -> ExecutionResourceAuthority {
795            ExecutionResourceAuthority::PlanRuntime
796        }
797
798        fn plan_runtime_resource_snapshot(&self) -> Result<Option<PlanRuntimeResourceSnapshot>> {
799            PlanRuntimeResourceSnapshot::new(1_000, 900, 700, 700, 400, 300, 200, 0, 0).map(Some)
800        }
801
802        async fn prefill(&self, input: &PrefillInput) -> Result<PrefillOutput> {
803            self.inner.prefill(input).await
804        }
805
806        async fn decode(&self, input: &DecodeInput) -> Result<DecodeOutput> {
807            self.inner.decode(input).await
808        }
809
810        fn capabilities(&self) -> ExecutorCapabilities {
811            self.inner.capabilities()
812        }
813
814        fn status(&self) -> ExecutorStatus {
815            self.inner.status()
816        }
817    }
818
819    struct CountingKvFactory {
820        calls: Arc<AtomicUsize>,
821    }
822
823    #[async_trait::async_trait]
824    impl crate::registry::ComponentFactory<Arc<dyn KvCacheManager + Send + Sync>>
825        for CountingKvFactory
826    {
827        async fn create(
828            &self,
829            _config: &ComponentConfig,
830        ) -> Result<Arc<dyn KvCacheManager + Send + Sync>> {
831            self.calls.fetch_add(1, Ordering::Relaxed);
832            Ok(Arc::new(ferrum_testkit::MockKvCacheManager::new(8)))
833        }
834
835        fn metadata(&self) -> crate::registry::ComponentMetadata {
836            crate::registry::ComponentMetadata::default()
837        }
838    }
839
840    #[async_trait::async_trait]
841    impl RecurrentStateManager for NoopRecurrentStateManager {
842        async fn allocate(
843            &self,
844            _spec: &RecurrentStateSpec,
845        ) -> Result<Arc<dyn RecurrentStateHandle>> {
846            Err(FerrumError::unsupported(
847                "noop recurrent-state manager does not allocate",
848            ))
849        }
850
851        async fn deallocate(&self, _request_id: RequestId) -> Result<()> {
852            Ok(())
853        }
854
855        fn can_allocate(&self, _spec: &RecurrentStateSpec) -> bool {
856            false
857        }
858
859        fn get_handle(&self, _request_id: RequestId) -> Option<Arc<dyn RecurrentStateHandle>> {
860            None
861        }
862
863        fn list_handles(&self) -> Vec<(RequestId, Arc<dyn RecurrentStateHandle>)> {
864            Vec::new()
865        }
866
867        fn stats(&self) -> RecurrentStateManagerStats {
868            RecurrentStateManagerStats {
869                total_memory_bytes: 0,
870                used_memory_bytes: 0,
871                active_states: 0,
872                active_state_tensors: 0,
873                total_batch_slots: 0,
874                used_batch_slots: 0,
875                allocation_count: 0,
876                allocation_failures: 0,
877                eviction_count: 0,
878            }
879        }
880
881        async fn reset(&self) -> Result<()> {
882            Ok(())
883        }
884    }
885
886    #[test]
887    fn test_builder_creation() {
888        let config = EngineConfig::default();
889        let builder = EngineBuilder::new(config);
890
891        assert!(builder.tokenizer_name.is_none());
892        assert!(builder.custom_recurrent_state_manager.is_none());
893    }
894
895    #[test]
896    fn test_builder_with_overrides() {
897        let config = EngineConfig::default();
898        let builder = EngineBuilder::new(config)
899            .with_tokenizer("custom_tokenizer")
900            .with_sampler("greedy")
901            .with_scheduler("priority")
902            .with_kv_cache("paged")
903            .with_executor("custom_executor");
904
905        assert_eq!(builder.tokenizer_name, Some("custom_tokenizer".to_string()));
906        assert_eq!(builder.sampler_name, Some("greedy".to_string()));
907        assert_eq!(builder.scheduler_name, Some("priority".to_string()));
908        assert_eq!(builder.kv_cache_name, Some("paged".to_string()));
909        assert_eq!(builder.executor_name, Some("custom_executor".to_string()));
910    }
911
912    #[test]
913    fn test_builder_with_custom_recurrent_state_manager() {
914        let config = EngineConfig::default();
915        let manager = Arc::new(NoopRecurrentStateManager);
916        let builder = EngineBuilder::new(config).with_custom_recurrent_state_manager(manager);
917
918        assert!(builder.custom_recurrent_state_manager.is_some());
919    }
920
921    #[test]
922    fn test_builder_typed_model_path_selects_model_components() {
923        let mut config = EngineConfig::default();
924        config.backend.backend_options.insert(
925            "model_path".to_string(),
926            serde_json::Value::String("/models/target".to_string()),
927        );
928        let builder = EngineBuilder::new(config);
929
930        assert!(builder.has_typed_model_path());
931        assert_eq!(builder.resolve_tokenizer_name(), "huggingface");
932        assert_eq!(builder.resolve_executor_name(), "llm");
933    }
934
935    #[test]
936    fn test_builder_retains_one_typed_source_bundle_for_components() {
937        let root = std::env::temp_dir().join(format!(
938            "ferrum-builder-source-bundle-{}-{}",
939            std::process::id(),
940            std::time::SystemTime::now()
941                .duration_since(std::time::UNIX_EPOCH)
942                .unwrap()
943                .as_nanos()
944        ));
945        std::fs::create_dir_all(&root).unwrap();
946        std::fs::write(
947            root.join("config.json"),
948            br#"{"architectures":["Fixture"]}"#,
949        )
950        .unwrap();
951        std::fs::write(root.join("tokenizer.json"), br#"{"version":"1.0"}"#).unwrap();
952        std::fs::write(root.join("model.safetensors"), b"fixture").unwrap();
953        let original = ferrum_interfaces::vnext::OriginalModelSource {
954            kind: ferrum_interfaces::vnext::ModelSourceKind::LocalDirectory,
955            location: root.display().to_string(),
956            requested_revision: None,
957        };
958        let sources = Arc::new(
959            ProductionModelSourceBundle::open(
960                &root,
961                &root,
962                ferrum_models::vnext::ProductionWeightArtifact::safetensors_directory(&root),
963                ferrum_interfaces::vnext::OriginalModelSources {
964                    semantic: original.clone(),
965                    tokenizer: original.clone(),
966                    weights: original,
967                },
968            )
969            .unwrap(),
970        );
971
972        let builder =
973            EngineBuilder::new(EngineConfig::default()).with_model_sources(Arc::clone(&sources));
974        assert!(builder.has_typed_model_path());
975        assert!(Arc::ptr_eq(
976            builder.model_sources.as_ref().unwrap(),
977            &sources
978        ));
979        assert_eq!(builder.resolve_tokenizer_name(), "huggingface");
980        assert_eq!(builder.resolve_executor_name(), "llm");
981        std::fs::remove_dir_all(root).unwrap();
982    }
983
984    #[test]
985    fn test_builder_typed_spec_options_parse_from_component_config() {
986        let mut config = EngineConfig::default();
987        config.backend.backend_options.insert(
988            "model_path".to_string(),
989            serde_json::Value::String("/models/target".to_string()),
990        );
991        config.backend.backend_options.insert(
992            "spec_draft".to_string(),
993            serde_json::Value::String("/models/draft".to_string()),
994        );
995        config.backend.backend_options.insert(
996            "spec_n".to_string(),
997            serde_json::Value::Number(serde_json::Number::from(6)),
998        );
999        let component_config = ComponentConfig::from_engine_config(&config);
1000
1001        assert_eq!(
1002            component_config.get_string_option("spec_draft").as_deref(),
1003            Some("/models/draft")
1004        );
1005        assert_eq!(component_config.get_option::<usize>("spec_n"), Some(6));
1006    }
1007
1008    #[test]
1009    fn test_builder_cuda_recurrent_state_manager_uses_recurrent_state_slot_cap() {
1010        let mut config = EngineConfig::default();
1011        config.backend.device = Device::CUDA(0);
1012        config.runtime.recurrent_state_max_slots = Some(2);
1013        let manager = default_recurrent_state_manager(&config)
1014            .expect("cuda product path should install admission recurrent-state manager");
1015        let spec = |request_id| RecurrentStateSpec {
1016            request_id,
1017            num_layers: 1,
1018            tensors: vec![RecurrentStateTensorSpec::new(
1019                0,
1020                "delta_state",
1021                vec![1, 1, 1],
1022                DataType::FP32,
1023            )],
1024            device: Device::CUDA(0),
1025            max_batch_slots: 1,
1026        };
1027
1028        tokio_test::block_on(manager.allocate(&spec(RequestId::new()))).unwrap();
1029        tokio_test::block_on(manager.allocate(&spec(RequestId::new()))).unwrap();
1030        let err = tokio_test::block_on(manager.allocate(&spec(RequestId::new())))
1031            .expect_err("third recurrent allocation should exceed the two-slot cap");
1032
1033        assert!(matches!(err, FerrumError::ResourceExhausted { .. }));
1034        let stats = manager.stats();
1035        assert_eq!(stats.total_batch_slots, 2);
1036        assert_eq!(stats.used_batch_slots, 2);
1037        assert_eq!(stats.allocation_failures, 1);
1038    }
1039
1040    #[test]
1041    fn test_builder_validates_layer_split_plan_without_executor_reject() {
1042        let mut config = EngineConfig::default();
1043        config.backend.backend_options.insert(
1044            "model_path".to_string(),
1045            serde_json::Value::String("/models/target".to_string()),
1046        );
1047        config.backend.backend_options.insert(
1048            "selected_distributed_strategy".to_string(),
1049            serde_json::Value::String("layer_split".to_string()),
1050        );
1051        config.backend.backend_options.insert(
1052            "requested_gpu_devices".to_string(),
1053            serde_json::json!([0, 1]),
1054        );
1055        config.backend.backend_options.insert(
1056            "selected_gpu_devices".to_string(),
1057            serde_json::json!([0, 1]),
1058        );
1059        config.backend.backend_options.insert(
1060            "selected_layer_split_plan".to_string(),
1061            serde_json::Value::String(
1062                "stage0:cuda:0:layers=0-39;stage1:cuda:1:layers=40-79".to_string(),
1063            ),
1064        );
1065        config.backend.backend_options.insert(
1066            "selected_layer_split_stages".to_string(),
1067            serde_json::json!([
1068                {"stage": 0, "device": 0, "layer_start": 0, "layer_end": 39},
1069                {"stage": 1, "device": 1, "layer_start": 40, "layer_end": 79}
1070            ]),
1071        );
1072        let component_config = ComponentConfig::from_engine_config(&config);
1073
1074        validate_layer_split_plan(&component_config).unwrap();
1075    }
1076
1077    #[tokio::test]
1078    async fn test_builder_rejects_invalid_layer_split_plan_before_executor_build() {
1079        let mut config = EngineConfig::default();
1080        config.backend.backend_options.insert(
1081            "model_path".to_string(),
1082            serde_json::Value::String("/models/target".to_string()),
1083        );
1084        config.backend.backend_options.insert(
1085            "selected_distributed_strategy".to_string(),
1086            serde_json::Value::String("layer_split".to_string()),
1087        );
1088        config.backend.backend_options.insert(
1089            "requested_gpu_devices".to_string(),
1090            serde_json::json!([0, 1]),
1091        );
1092        config.backend.backend_options.insert(
1093            "selected_gpu_devices".to_string(),
1094            serde_json::json!([0, 1]),
1095        );
1096        config.backend.backend_options.insert(
1097            "selected_layer_split_plan".to_string(),
1098            serde_json::Value::String(
1099                "stage0:cuda:0:layers=auto;stage1:cuda:1:layers=auto".to_string(),
1100            ),
1101        );
1102
1103        let err = match EngineBuilder::new(config).build().await {
1104            Ok(_) => panic!("layer_split build unexpectedly succeeded"),
1105            Err(err) => err,
1106        };
1107        assert!(err.to_string().contains("expected START-END"));
1108    }
1109
1110    #[test]
1111    fn test_resolve_defaults() {
1112        let config = EngineConfig::default();
1113        let builder = EngineBuilder::new(config);
1114
1115        assert_eq!(builder.resolve_sampler_name(), "multinomial");
1116        assert_eq!(builder.resolve_kv_cache_name(), "default");
1117    }
1118
1119    #[tokio::test]
1120    async fn test_build_with_defaults() {
1121        let config = EngineConfig::default();
1122        let result = EngineBuilder::new(config).build().await;
1123
1124        // Should succeed with stub components
1125        assert!(result.is_ok());
1126    }
1127
1128    #[tokio::test]
1129    async fn startup_preparation_runs_once_and_blocks_engine_publication_on_failure() {
1130        let success_calls = Arc::new(AtomicUsize::new(0));
1131        let success: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(StartupProbeExecutor {
1132            inner: ferrum_testkit::MockModelExecutor::instant(128),
1133            calls: Arc::clone(&success_calls),
1134            fail: false,
1135        });
1136        EngineBuilder::new(EngineConfig::default())
1137            .with_custom_executor(success)
1138            .build()
1139            .await
1140            .expect("successful startup preparation builds the engine");
1141        assert_eq!(success_calls.load(Ordering::Relaxed), 1);
1142
1143        let failure_calls = Arc::new(AtomicUsize::new(0));
1144        let failure: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(StartupProbeExecutor {
1145            inner: ferrum_testkit::MockModelExecutor::instant(128),
1146            calls: Arc::clone(&failure_calls),
1147            fail: true,
1148        });
1149        let error = EngineBuilder::new(EngineConfig::default())
1150            .with_custom_executor(failure)
1151            .build()
1152            .await
1153            .err()
1154            .expect("failed startup preparation must stop engine construction");
1155        assert!(error.to_string().contains("startup preparation rejected"));
1156        assert_eq!(failure_calls.load(Ordering::Relaxed), 1);
1157    }
1158
1159    #[tokio::test]
1160    async fn product_profile_captures_startup_events_before_engine_readiness() {
1161        let trace_path = std::env::temp_dir().join(format!(
1162            "ferrum-builder-startup-profile-{}-{}.jsonl",
1163            std::process::id(),
1164            std::time::SystemTime::now()
1165                .duration_since(std::time::UNIX_EPOCH)
1166                .unwrap()
1167                .as_nanos()
1168        ));
1169        let _ = std::fs::remove_file(&trace_path);
1170        let saw_sink_during_startup = Arc::new(AtomicBool::new(false));
1171        let executor: Arc<dyn ModelExecutor + Send + Sync> =
1172            Arc::new(ProfileStartupProbeExecutor {
1173                inner: ferrum_testkit::MockModelExecutor::instant(128),
1174                event_sink: Mutex::new(None),
1175                saw_sink_during_startup: Arc::clone(&saw_sink_during_startup),
1176            });
1177        let mut config = EngineConfig::default();
1178        config.runtime.profile_jsonl = Some(trace_path.clone());
1179        config.runtime.profile_entrypoint = Some(ferrum_types::ProfileEntrypoint::Run);
1180
1181        let engine = EngineBuilder::new(config)
1182            .with_custom_executor(executor)
1183            .build()
1184            .await
1185            .expect("profile-enabled startup builds the engine");
1186        assert!(saw_sink_during_startup.load(Ordering::Acquire));
1187        engine.shutdown().await.unwrap();
1188
1189        let startup_events = std::fs::read_to_string(&trace_path)
1190            .unwrap()
1191            .lines()
1192            .map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
1193            .filter(|event| event["phase"] == "vnext.request_accepted")
1194            .count();
1195        assert_eq!(startup_events, 1);
1196        let _ = std::fs::remove_file(trace_path);
1197    }
1198
1199    #[tokio::test]
1200    async fn product_without_profile_does_not_attach_execution_event_sink() {
1201        let saw_sink_during_startup = Arc::new(AtomicBool::new(false));
1202        let executor: Arc<dyn ModelExecutor + Send + Sync> =
1203            Arc::new(ProfileStartupProbeExecutor {
1204                inner: ferrum_testkit::MockModelExecutor::instant(128),
1205                event_sink: Mutex::new(None),
1206                saw_sink_during_startup: Arc::clone(&saw_sink_during_startup),
1207            });
1208
1209        let engine = EngineBuilder::new(EngineConfig::default())
1210            .with_custom_executor(executor)
1211            .build()
1212            .await
1213            .expect("profile-disabled engine builds");
1214        assert!(!saw_sink_during_startup.load(Ordering::Acquire));
1215        engine.shutdown().await.unwrap();
1216    }
1217
1218    #[tokio::test]
1219    async fn plan_runtime_without_resolved_plan_rejects_before_legacy_kv_factory() {
1220        let calls = Arc::new(AtomicUsize::new(0));
1221        let registry = Arc::new(ComponentRegistry::with_defaults());
1222        registry.register_kv_cache_factory(
1223            "default",
1224            Arc::new(CountingKvFactory {
1225                calls: Arc::clone(&calls),
1226            }),
1227        );
1228        let executor: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(PlanRuntimeBuilderExecutor {
1229            inner: ferrum_testkit::MockModelExecutor::instant(128),
1230        });
1231
1232        let result = EngineBuilder::with_registry(EngineConfig::default(), registry)
1233            .with_custom_executor(executor)
1234            .build()
1235            .await;
1236
1237        let error = result
1238            .err()
1239            .expect("plan runtime without a resolved plan must fail closed");
1240        assert!(error
1241            .to_string()
1242            .contains("authoritative ResolvedModelPlan"));
1243        assert_eq!(calls.load(Ordering::Relaxed), 0);
1244    }
1245
1246    #[tokio::test]
1247    async fn plan_runtime_build_rejects_legacy_resource_override() {
1248        let executor: Arc<dyn ModelExecutor + Send + Sync> = Arc::new(PlanRuntimeBuilderExecutor {
1249            inner: ferrum_testkit::MockModelExecutor::instant(128),
1250        });
1251        let kv_cache: Arc<dyn KvCacheManager + Send + Sync> =
1252            Arc::new(ferrum_testkit::MockKvCacheManager::new(8));
1253
1254        let error = EngineBuilder::new(EngineConfig::default())
1255            .with_custom_executor(executor)
1256            .with_custom_kv_cache(kv_cache)
1257            .build()
1258            .await
1259            .err()
1260            .expect("plan runtime must reject a legacy KV manager override");
1261
1262        assert!(error
1263            .to_string()
1264            .contains("cannot be combined with a legacy engine KV-cache override"));
1265    }
1266}