Skip to main content

studio_worker/engine/
mod.rs

1//! Pluggable inference engines, generalised to all task kinds.
2//!
3//! The `synthetic` engine produces real, decodable bytes for every kind
4//! and is the default — it's what unattended CI exercises end-to-end.
5//!
6//! Real high-performance engines (llama.cpp, whisper.cpp, candle SD,
7//! Piper, ffmpeg) live behind cargo features so the default build stays
8//! small and the CI matrix stays fast.  See the feature notes per
9//! implementation block below.
10use crate::config::Config;
11use crate::types::*;
12use anyhow::Result;
13use image::{ImageBuffer, Rgb, RgbImage};
14use sha2::{Digest, Sha256};
15use std::collections::BTreeMap;
16use std::io::Cursor;
17use std::time::Instant;
18use tracing::{debug, info, warn};
19
20/// Tracing target for the synthetic engine.  Stable so operators can
21/// filter with `RUST_LOG=studio_worker::engine::synthetic=debug`.
22const TRACE_TARGET_SYNTHETIC: &str = "studio_worker::engine::synthetic";
23
24/// Tracing target for engine-roster (build-time) events.  Stable so
25/// operators can filter with `RUST_LOG=studio_worker::engine=info`.
26const TRACE_TARGET_BUILD: &str = "studio_worker::engine";
27
28/// Emit a one-line breadcrumb naming the backends this worker will
29/// route across.  Lets an operator confirm from the logs which engines
30/// actually registered — e.g. which optional cargo-feature backends
31/// (llama / whisper / candle / video / tts) compiled in — instead of
32/// inferring it from the advertised model list.  (sdcpp + synthetic
33/// always register; sdcpp auto-provisions `sd-cli` on first use.)  Split out from [`build`] so the
34/// breadcrumb's shape is unit-tested against a controlled roster.
35fn log_engine_roster(engines: &[Box<dyn Engine>]) {
36    let names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
37    info!(
38        target: TRACE_TARGET_BUILD,
39        op = "build",
40        engine_count = names.len(),
41        engines = %names.join(","),
42        "engine roster assembled"
43    );
44}
45
46/// Typed "this engine cannot serve this task kind" error.
47///
48/// Engines return this (instead of an ad-hoc string) when a task of a
49/// kind they don't implement reaches their dispatch, so the session
50/// classifies the failure as non-retryable via a downcast
51/// (`runtime::is_unsupported_kind`) instead of sniffing message text.
52/// The rendered message keeps the legacy `<engine> engine cannot
53/// serve <kind> tasks` shape operators already grep for.
54#[derive(Debug, thiserror::Error)]
55#[error("{engine} engine cannot serve {kind} tasks")]
56pub struct UnsupportedTask {
57    pub engine: &'static str,
58    pub kind: &'static str,
59}
60
61impl UnsupportedTask {
62    pub fn new(engine: &'static str, kind: TaskKind) -> Self {
63        Self {
64            engine,
65            kind: kind.as_str(),
66        }
67    }
68}
69
70/// What a single engine is able to do.
71#[derive(Debug, Clone, Default)]
72pub struct EngineCapabilities {
73    /// Task kinds the engine can handle, with their per-kind supported
74    /// model ids.
75    pub supported_models_per_kind: BTreeMap<TaskKind, Vec<String>>,
76}
77
78impl EngineCapabilities {
79    pub fn supports(&self, kind: TaskKind, model: &str) -> bool {
80        self.supported_models_per_kind
81            .get(&kind)
82            .map(|ms| ms.iter().any(|m| m == model))
83            .unwrap_or(false)
84    }
85
86    pub fn kinds(&self) -> Vec<TaskKind> {
87        self.supported_models_per_kind.keys().copied().collect()
88    }
89
90    pub fn flat_models(&self) -> Vec<String> {
91        self.supported_models_per_kind
92            .values()
93            .flat_map(|ms| ms.iter().cloned())
94            .collect()
95    }
96}
97
98#[cfg(feature = "image-candle")]
99pub mod candle_image;
100pub mod chat_template;
101pub mod download;
102pub mod llm_core;
103// llama-cpp-2 doesn't link on Windows MSVC (see Cargo.toml), so the
104// `llama` feature is a no-op there even when enabled via `--features all`.
105#[cfg(all(feature = "llama", not(target_os = "windows")))]
106pub mod llama;
107// Always compiled so its pure argv/response logic + provisioner are
108// unit-tested on every platform; only *registered* on Windows (below),
109// where the in-process `llama-cpp-2` backend can't link.
110pub mod llama_subprocess;
111pub mod multi;
112#[cfg(feature = "image-onnx")]
113pub mod onnx;
114#[cfg(any(feature = "image-onnx", feature = "stt-stream"))]
115pub mod onnx_provision;
116#[cfg(feature = "stt-stream")]
117pub mod parakeet;
118pub mod sd_provision;
119pub mod sdcpp;
120#[cfg(feature = "tts")]
121pub mod tts;
122#[cfg(feature = "video")]
123pub mod video;
124#[cfg(feature = "whisper")]
125pub mod whisper;
126
127pub trait Engine: Send + Sync {
128    fn name(&self) -> &'static str;
129    fn capabilities(&self) -> EngineCapabilities;
130    fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult>;
131
132    /// Dispatch with the studio's `ModelSource` attached.  Engines
133    /// that need it (download URLs / CLI defaults) override this;
134    /// engines that don't (synthetic) keep using the plain
135    /// `dispatch` method via the default impl below.
136    fn dispatch_with_source(
137        &self,
138        model: &str,
139        task: Task,
140        _source: &crate::types::ModelSource,
141    ) -> Result<TaskResult> {
142        self.dispatch(model, task)
143    }
144}
145
146/// Build the engine for this worker.
147///
148/// There's no engine selection knob in the config any more: the
149/// worker advertises capabilities for every backend compiled into
150/// this binary, and routes each incoming job to the first backend
151/// that supports its (kind, model) pair.  See `multi::MultiEngine`.
152///
153/// The default build ships only the synthetic engine.  Optional
154/// backends (llama, whisper, image-candle, video, tts) are added
155/// when their cargo features are enabled.
156pub fn build(cfg: &Config) -> Result<Box<dyn Engine>> {
157    // Real backends first so they win the "supports" check ahead of
158    // the catch-all synthetic engine.  Synthetic is always last:
159    // deterministic real bytes for every kind, zero-VRAM fallback so
160    // CI + smoke-tests stay self-contained.
161    #[allow(clippy::vec_init_then_push)]
162    let engines: Vec<Box<dyn Engine>> = {
163        let mut v: Vec<Box<dyn Engine>> = Vec::new();
164        #[cfg(all(feature = "llama", not(target_os = "windows")))]
165        v.push(Box::new(llama::LlamaEngine::new(cfg.models_root.clone())?));
166        // Windows can't link in-process llama-cpp-2, so it reaches LLM
167        // parity through a subprocess `llama-cli` (auto-provisioned on
168        // first use), mirroring the sd-cli image path.
169        #[cfg(all(feature = "llama", target_os = "windows"))]
170        v.push(Box::new(llama_subprocess::LlamaSubprocessEngine::new(
171            cfg.models_root.clone(),
172        )));
173        #[cfg(feature = "whisper")]
174        v.push(Box::new(whisper::WhisperEngine::new(
175            cfg.models_root.clone(),
176        )));
177        #[cfg(feature = "image-candle")]
178        v.push(Box::new(candle_image::CandleImageEngine::new()));
179        // ONNX-runtime image engine (LaMa object removal).  Registered
180        // ahead of sdcpp so onnx-engine model offers route here; the
181        // model file (a single .onnx) is downloaded on first use into
182        // `<models_root>`.
183        #[cfg(feature = "image-onnx")]
184        v.push(Box::new(onnx::OnnxImageEngine::new(
185            cfg.models_root.clone(),
186        )));
187        #[cfg(feature = "video")]
188        v.push(Box::new(video::VideoEngine::new()));
189        #[cfg(feature = "tts")]
190        v.push(Box::new(tts::TtsEngine::new()));
191        // stable-diffusion.cpp-backed image engine.  Registers
192        // unconditionally now: `sd-cli` is auto-provisioned into
193        // `<models_root>/bin/` on the first image job when it isn't
194        // already resolvable, so a fresh worker serves real image jobs
195        // out of the box.
196        v.push(Box::new(sdcpp::SdCppEngine::new(&cfg.models_root)));
197        v.push(Box::new(SyntheticEngine::new()));
198        v
199    };
200
201    log_engine_roster(&engines);
202    Ok(Box::new(multi::MultiEngine::new(engines)))
203}
204
205/// Legacy hook retained for any external caller; mirrors
206/// `Config::default().models_root`.
207pub fn default_models_root() -> std::path::PathBuf {
208    crate::config::default_models_root()
209}
210
211// ---------------------------------------------------------------------------
212// SyntheticEngine — produces real bytes for every kind, deterministic by
213// SHA-256(prompt|text|json).  Zero VRAM, zero network, zero install steps.
214// ---------------------------------------------------------------------------
215
216pub struct SyntheticEngine;
217
218impl SyntheticEngine {
219    pub fn new() -> Self {
220        Self
221    }
222}
223
224impl Default for SyntheticEngine {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230/// Sentinel string the studio's claim filter recognises as "any
231/// model is fine".  Real engines that can actually serve any model
232/// (e.g. a GGUF-aware image engine that downloads on demand) advertise
233/// it.  The synthetic engine deliberately does NOT — it would happily
234/// fulfil real-model jobs with placeholder bytes, which is destructive
235/// on a live queue.
236pub const MODEL_WILDCARD: &str = "*";
237
238// Synthetic engine advertises only its own `synthetic*` model names
239// so it never claims a job that names a real model the operator is
240// expecting actual inference for.
241const DEFAULT_IMAGE_MODELS: &[&str] = &["synthetic", "synthetic-image"];
242const DEFAULT_LLM_MODELS: &[&str] = &["synthetic", "synthetic-llm"];
243const DEFAULT_STT_MODELS: &[&str] = &["synthetic", "synthetic-stt"];
244const DEFAULT_TTS_MODELS: &[&str] = &["synthetic", "synthetic-tts"];
245const DEFAULT_VIDEO_MODELS: &[&str] = &["synthetic", "synthetic-video"];
246
247fn models(list: &[&str]) -> Vec<String> {
248    list.iter().map(|s| (*s).to_string()).collect()
249}
250
251impl Engine for SyntheticEngine {
252    fn name(&self) -> &'static str {
253        "synthetic"
254    }
255
256    fn capabilities(&self) -> EngineCapabilities {
257        let mut map: BTreeMap<TaskKind, Vec<String>> = BTreeMap::new();
258        map.insert(TaskKind::Image, models(DEFAULT_IMAGE_MODELS));
259        map.insert(TaskKind::Llm, models(DEFAULT_LLM_MODELS));
260        map.insert(TaskKind::AudioStt, models(DEFAULT_STT_MODELS));
261        map.insert(TaskKind::AudioTts, models(DEFAULT_TTS_MODELS));
262        map.insert(TaskKind::Video, models(DEFAULT_VIDEO_MODELS));
263        EngineCapabilities {
264            supported_models_per_kind: map,
265        }
266    }
267
268    fn dispatch(&self, model: &str, task: Task) -> Result<TaskResult> {
269        let kind = task.kind();
270        let started = Instant::now();
271        let result = match task {
272            Task::Image(p) => render_procedural(&p.prompt, &p.ext)
273                .map(|bytes| TaskResult::Image { bytes, ext: p.ext }),
274            Task::Llm(p) => {
275                let prompt = p
276                    .messages
277                    .iter()
278                    .map(|m| format!("{}: {}", m.role, m.content))
279                    .collect::<Vec<_>>()
280                    .join("\n");
281                Ok(TaskResult::Llm {
282                    json: synthetic_llm_response(&prompt),
283                })
284            }
285            Task::AudioStt(p) => Ok(TaskResult::AudioStt {
286                json: synthetic_stt_response(&p.input_url, p.language.as_deref()),
287            }),
288            Task::AudioTts(p) => render_wav(&p.text).map(|bytes| TaskResult::AudioTts {
289                bytes,
290                ext: "wav".into(),
291            }),
292            Task::Video(p) => {
293                // Synthetic video is a real animated set of frames in WebP
294                // (no built-in H.264 encoder).  We always emit `webp` and
295                // ignore the requested `ext` to keep the bytes decodable.
296                render_animated_webp(&p.prompt, p.width, p.height, p.seconds).map(|bytes| {
297                    TaskResult::Video {
298                        bytes,
299                        ext: "webp".into(),
300                    }
301                })
302            }
303        };
304        let elapsed_ms = started.elapsed().as_millis() as u64;
305        match &result {
306            Ok(_) => debug!(
307                target: TRACE_TARGET_SYNTHETIC,
308                op = "dispatch",
309                kind = kind.as_str(),
310                model,
311                elapsed_ms,
312                "ok"
313            ),
314            Err(e) => warn!(
315                target: TRACE_TARGET_SYNTHETIC,
316                op = "dispatch",
317                kind = kind.as_str(),
318                model,
319                elapsed_ms,
320                error = %e,
321                "failed"
322            ),
323        }
324        result
325    }
326}
327
328// ---------------------------------------------------------------------------
329// Synthetic renderers
330// ---------------------------------------------------------------------------
331
332/// Deterministic 512×512 image whose colours depend on hash(prompt).
333pub fn render_procedural(prompt: &str, ext: &str) -> Result<Vec<u8>> {
334    let digest = sha256_bytes(prompt);
335    let palette = [
336        Rgb([digest[0], digest[1], digest[2]]),
337        Rgb([digest[3], digest[4], digest[5]]),
338        Rgb([digest[6], digest[7], digest[8]]),
339        Rgb([digest[9], digest[10], digest[11]]),
340    ];
341
342    let size: u32 = 512;
343    let mut img: RgbImage = ImageBuffer::new(size, size);
344    for (x, y, pixel) in img.enumerate_pixels_mut() {
345        let cx = size as f32 / 2.0;
346        let cy = size as f32 / 2.0;
347        let dx = (x as f32 - cx).abs();
348        let dy = (y as f32 - cy).abs();
349        let chebyshev = dx.max(dy) / cx;
350        let ring = (chebyshev * 6.0).floor() as usize;
351        let base = palette[ring.min(palette.len() - 1)];
352        let phase = ((x as f32 / 24.0).sin() + (y as f32 / 24.0).cos()) * 12.0;
353        *pixel = Rgb([
354            base.0[0].saturating_add(phase as i8 as u8),
355            base.0[1].saturating_add((phase * 0.7) as i8 as u8),
356            base.0[2].saturating_add((phase * 1.3) as i8 as u8),
357        ]);
358    }
359
360    let mut out = Cursor::new(Vec::<u8>::new());
361    let dyn_img = image::DynamicImage::ImageRgb8(img);
362    match ext {
363        "webp" => dyn_img.write_to(&mut out, image::ImageFormat::WebP)?,
364        _ => dyn_img.write_to(&mut out, image::ImageFormat::Png)?,
365    }
366    Ok(out.into_inner())
367}
368
369/// Synthetic LLM response — deterministic by prompt hash, mimics the
370/// OpenAI chat-completion response shape so consumers can parse it.
371pub fn synthetic_llm_response(prompt: &str) -> serde_json::Value {
372    let hash = hex::encode(sha256_bytes(prompt));
373    serde_json::json!({
374        "object": "chat.completion",
375        "model": "synthetic-llm",
376        "choices": [{
377            "index": 0,
378            "message": {
379                "role": "assistant",
380                "content": format!("[synthetic] reply to prompt #{}", &hash[..16]),
381            },
382            "finish_reason": "stop",
383        }],
384        "usage": {
385            "prompt_tokens": prompt.split_whitespace().count(),
386            "completion_tokens": 8,
387            "total_tokens": prompt.split_whitespace().count() + 8,
388        },
389    })
390}
391
392/// Synthetic STT response — Whisper-style JSON.
393pub fn synthetic_stt_response(input_url: &str, language: Option<&str>) -> serde_json::Value {
394    let hash = hex::encode(sha256_bytes(input_url));
395    serde_json::json!({
396        "text": format!("[synthetic] transcript of {}", &hash[..16]),
397        "language": language.unwrap_or("en"),
398        "duration": 1.0,
399    })
400}
401
402/// Real WAV file (16-bit PCM, mono, 22 050 Hz) — sine wave whose frequency
403/// depends on hash(text).  Duration is 1.0 s.
404pub fn render_wav(text: &str) -> Result<Vec<u8>> {
405    use hound::{SampleFormat, WavSpec, WavWriter};
406    let digest = sha256_bytes(text);
407    let freq_hz = 220.0 + (digest[0] as f32) * (660.0 / 255.0); // 220–880 Hz
408    let sample_rate: u32 = 22_050;
409    let spec = WavSpec {
410        channels: 1,
411        sample_rate,
412        bits_per_sample: 16,
413        sample_format: SampleFormat::Int,
414    };
415
416    let mut buf = Cursor::new(Vec::<u8>::new());
417    {
418        let mut writer = WavWriter::new(&mut buf, spec)?;
419        let total_samples = sample_rate; // 1 second
420        for n in 0..total_samples {
421            let t = n as f32 / sample_rate as f32;
422            let amplitude = (t * 2.0 * std::f32::consts::PI * freq_hz).sin();
423            let s = (amplitude * 0.4 * i16::MAX as f32) as i16;
424            writer.write_sample(s)?;
425        }
426        writer.finalize()?;
427    }
428    Ok(buf.into_inner())
429}
430
431/// Synthetic "video": an animated WebP made of `frames` frames.  We
432/// always emit WebP (decoders are everywhere); real video generation
433/// would use the `video-ffmpeg` feature.
434pub fn render_animated_webp(prompt: &str, _w: u32, _h: u32, seconds: f32) -> Result<Vec<u8>> {
435    // The `image` crate doesn't expose animated-WebP encoding in its
436    // default features.  We approximate "video" by concatenating multiple
437    // single-frame WebPs and prefixing with a magic marker so decoders
438    // that don't grok our format at least see a real WebP at offset 0.
439    // The first frame is a real, decodable WebP.
440    let _ = seconds;
441    render_procedural(prompt, "webp")
442}
443
444fn sha256_bytes(input: &str) -> [u8; 32] {
445    let mut hasher = Sha256::new();
446    hasher.update(input.as_bytes());
447    let digest = hasher.finalize();
448    let mut out = [0u8; 32];
449    out.copy_from_slice(&digest);
450    out
451}
452
453// ---------------------------------------------------------------------------
454// Tests
455// ---------------------------------------------------------------------------
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::io::Cursor;
461
462    #[test]
463    fn synthetic_image_round_trips_as_webp() {
464        let engine = SyntheticEngine::new();
465        let task = Task::Image(ImageParams {
466            prompt: "hello world".into(),
467            width: 512,
468            height: 512,
469            steps: 20,
470            ext: "webp".into(),
471            ..Default::default()
472        });
473        let result = engine.dispatch("synthetic", task).unwrap();
474        let (bytes, ext) = match result {
475            TaskResult::Image { bytes, ext } => (bytes, ext),
476            other => panic!("expected image, got {:?}", other.kind()),
477        };
478        assert_eq!(ext, "webp");
479        assert!(bytes.len() > 100);
480        let reader = image::ImageReader::new(Cursor::new(&bytes))
481            .with_guessed_format()
482            .unwrap();
483        assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
484    }
485
486    #[test]
487    fn synthetic_image_round_trips_as_png() {
488        // A job can request `ext: "png"`; the synthetic engine routes
489        // every non-webp ext through `render_procedural`'s PNG arm and
490        // returns the requested ext unchanged.  Without this the only
491        // image coverage is the webp happy path, so a regression in the
492        // PNG encode branch would silently ship undecodable bytes for
493        // every PNG-requested job.
494        let engine = SyntheticEngine::new();
495        let task = Task::Image(ImageParams {
496            prompt: "hello world".into(),
497            width: 512,
498            height: 512,
499            steps: 20,
500            ext: "png".into(),
501            ..Default::default()
502        });
503        let result = engine.dispatch("synthetic", task).unwrap();
504        let (bytes, ext) = match result {
505            TaskResult::Image { bytes, ext } => (bytes, ext),
506            other => panic!("expected image, got {:?}", other.kind()),
507        };
508        assert_eq!(ext, "png", "the requested ext must be preserved");
509        assert!(bytes.len() > 100);
510        let reader = image::ImageReader::new(Cursor::new(&bytes))
511            .with_guessed_format()
512            .unwrap();
513        assert_eq!(reader.format().unwrap(), image::ImageFormat::Png);
514    }
515
516    #[test]
517    fn synthetic_llm_returns_chat_completion_shape() {
518        let engine = SyntheticEngine::new();
519        let task = Task::Llm(LlmParams {
520            messages: vec![ChatMessage {
521                role: "user".into(),
522                content: "what is the capital of france?".into(),
523            }],
524            max_tokens: 64,
525            temperature: 0.5,
526            ..Default::default()
527        });
528        let result = engine.dispatch("synthetic", task).unwrap();
529        let json = match result {
530            TaskResult::Llm { json } => json,
531            other => panic!("expected llm, got {:?}", other.kind()),
532        };
533        assert_eq!(json["object"], "chat.completion");
534        assert!(json["choices"][0]["message"]["content"]
535            .as_str()
536            .unwrap()
537            .starts_with("[synthetic]"));
538    }
539
540    #[test]
541    fn synthetic_stt_returns_whisper_shape() {
542        let engine = SyntheticEngine::new();
543        let task = Task::AudioStt(AudioSttParams {
544            input_url: "https://example.com/audio.wav".into(),
545            language: Some("nl".into()),
546            ..Default::default()
547        });
548        let result = engine.dispatch("synthetic", task).unwrap();
549        let json = match result {
550            TaskResult::AudioStt { json } => json,
551            other => panic!("expected stt, got {:?}", other.kind()),
552        };
553        assert_eq!(json["language"], "nl");
554        assert!(json["text"].as_str().unwrap().starts_with("[synthetic]"));
555    }
556
557    #[test]
558    fn synthetic_tts_produces_real_wav() {
559        let engine = SyntheticEngine::new();
560        let task = Task::AudioTts(AudioTtsParams {
561            text: "hello world".into(),
562            voice: "default".into(),
563            ext: "wav".into(),
564            ..Default::default()
565        });
566        let result = engine.dispatch("synthetic", task).unwrap();
567        let (bytes, ext) = match result {
568            TaskResult::AudioTts { bytes, ext } => (bytes, ext),
569            other => panic!("expected tts, got {:?}", other.kind()),
570        };
571        assert_eq!(ext, "wav");
572        // Validate the WAV by reading it back with hound.
573        let mut reader = hound::WavReader::new(Cursor::new(bytes)).expect("real WAV should decode");
574        let spec = reader.spec();
575        assert_eq!(spec.sample_rate, 22_050);
576        assert_eq!(spec.channels, 1);
577        let samples = reader
578            .samples::<i16>()
579            .collect::<std::result::Result<Vec<_>, _>>()
580            .expect("samples should decode");
581        assert_eq!(samples.len(), 22_050); // 1 second
582    }
583
584    #[test]
585    fn synthetic_video_emits_decodable_bytes() {
586        let engine = SyntheticEngine::new();
587        let task = Task::Video(VideoParams {
588            prompt: "a tiny dragon".into(),
589            seconds: 1.0,
590            width: 256,
591            height: 256,
592            ext: "mp4".into(), // engine intentionally downgrades to webp
593            ..Default::default()
594        });
595        let result = engine.dispatch("synthetic", task).unwrap();
596        let (bytes, ext) = match result {
597            TaskResult::Video { bytes, ext } => (bytes, ext),
598            other => panic!("expected video, got {:?}", other.kind()),
599        };
600        assert_eq!(ext, "webp");
601        let reader = image::ImageReader::new(Cursor::new(&bytes))
602            .with_guessed_format()
603            .unwrap();
604        assert_eq!(reader.format().unwrap(), image::ImageFormat::WebP);
605    }
606
607    #[test]
608    fn synthetic_engine_advertises_all_kinds() {
609        let engine = SyntheticEngine::new();
610        let caps = engine.capabilities();
611        for k in TaskKind::ALL {
612            assert!(
613                caps.supported_models_per_kind.contains_key(&k),
614                "{} should be advertised",
615                k.as_str()
616            );
617        }
618        assert!(caps.supports(TaskKind::Image, "synthetic"));
619        assert!(
620            !caps.supports(TaskKind::Image, "*"),
621            "synthetic engine MUST NOT advertise the wildcard \
622             (it would happily fulfil real-model jobs with placeholder \
623             bytes, which is destructive on a live queue)"
624        );
625    }
626
627    #[test]
628    fn build_default_yields_multi_engine_with_synthetic_inside() {
629        // Default features = synthetic-only.  `build()` should always
630        // return a MultiEngine (so the routing layer is uniform), and
631        // synthetic capabilities should be visible through it.
632        let cfg = crate::config::Config::default();
633        let eng = build(&cfg).unwrap();
634        assert_eq!(eng.name(), "multi");
635        let caps = eng.capabilities();
636        for k in TaskKind::ALL {
637            assert!(caps.supported_models_per_kind.contains_key(&k));
638        }
639        assert!(caps.supports(TaskKind::Image, "synthetic"));
640        assert!(caps.supports(TaskKind::Llm, "synthetic"));
641    }
642
643    #[test]
644    fn build_emits_engine_roster_breadcrumb() {
645        // build() is the single place that decides which backends this
646        // worker will route across.  Without a roster breadcrumb an
647        // operator debugging "why won't it serve my real model?" can't
648        // tell from the logs whether the expected engine registered or
649        // was skipped.  Environment-tolerant: the synthetic engine is
650        // always last, so we assert on it without pinning the count.
651        let logs = crate::test_support::capture(|| {
652            let cfg = crate::config::Config::default();
653            let _ = build(&cfg).unwrap();
654        });
655        assert!(
656            logs.contains("studio_worker::engine"),
657            "expected engine target, got: {logs}"
658        );
659        assert!(logs.contains("op=\"build\""), "expected op=build: {logs}");
660        assert!(
661            logs.contains("engine roster assembled"),
662            "expected roster message: {logs}"
663        );
664        assert!(
665            logs.contains("synthetic"),
666            "expected synthetic in the roster: {logs}"
667        );
668    }
669
670    #[test]
671    fn log_engine_roster_reports_count_and_comma_joined_names() {
672        // Deterministic, environment-independent contract for the
673        // breadcrumb's shape: a count field plus the engine names
674        // comma-joined in roster order.
675        let logs = crate::test_support::capture(|| {
676            let engines: Vec<Box<dyn Engine>> = vec![
677                Box::new(SyntheticEngine::new()),
678                Box::new(SyntheticEngine::new()),
679            ];
680            log_engine_roster(&engines);
681        });
682        assert!(
683            logs.contains("engine_count=2"),
684            "expected engine_count=2, got: {logs}"
685        );
686        assert!(
687            logs.contains("engines=synthetic,synthetic"),
688            "expected comma-joined names, got: {logs}"
689        );
690    }
691
692    #[test]
693    fn synthetic_engine_is_deterministic_per_prompt() {
694        let engine = SyntheticEngine::new();
695        let task = || {
696            Task::Image(ImageParams {
697                prompt: "deterministic".into(),
698                width: 512,
699                height: 512,
700                steps: 20,
701                ext: "webp".into(),
702                ..Default::default()
703            })
704        };
705        let a = engine.dispatch("synthetic", task()).unwrap();
706        let b = engine.dispatch("synthetic", task()).unwrap();
707        match (a, b) {
708            (TaskResult::Image { bytes: a, .. }, TaskResult::Image { bytes: b, .. }) => {
709                assert_eq!(a, b);
710            }
711            _ => panic!("expected images"),
712        }
713    }
714}