Skip to main content

mold_core/
expand.rs

1//! LLM-powered prompt expansion.
2//!
3//! Provides a `PromptExpander` trait with two backends:
4//! - `ApiExpander`: calls any OpenAI-compatible `/v1/chat/completions` endpoint
5//! - Local GGUF inference (in `mold-inference`, behind the `expand` feature flag)
6
7use std::collections::{HashMap, HashSet};
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12use crate::expand_prompts::{
13    build_batch_messages_with_context_for_task, build_remix_messages_with_context_for_task,
14    build_single_messages_for_task,
15};
16use crate::{ExpandTask, PromptTransformOperation, RemixDimension};
17
18/// Maximum number of prompt variations for Discord (embed character limit).
19pub const DISCORD_MAX_VARIATIONS: usize = 5;
20
21/// Maximum number of prompts requested from an expansion model in one
22/// completion. Large logical batches are assembled from bounded chunks so the
23/// response remains reliable without imposing a product-level batch limit.
24pub const EXPANSION_CHUNK_SIZE: usize = 4;
25
26/// Number of attempts allowed for each bounded chunk. A partial response keeps
27/// its non-empty prompts and retries only the missing count.
28pub const EXPANSION_CHUNK_ATTEMPTS: usize = 3;
29
30/// Per-request safety ceiling for prepared prompt expansion.
31///
32/// The total number of prints remains unbounded because clients may queue
33/// additional prepared batches. Keeping one reviewed set bounded prevents an
34/// accidental value from retaining billions of prompts and jobs in memory.
35pub const MAX_EXPANSION_VARIATIONS: usize = 10_000;
36
37/// Logical position metadata for one bounded expansion attempt.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub struct ExpansionAttempt {
40    /// One-based position of the first requested prompt.
41    pub start: usize,
42    /// Total prompts in the logical batch.
43    pub total: usize,
44}
45
46/// Per-family word limit and style notes override.
47#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct FamilyOverride {
49    /// Word limit for expanded prompts (overrides built-in default for this family).
50    pub word_limit: Option<u32>,
51    /// Style notes injected into the system prompt (overrides built-in default).
52    pub style_notes: Option<String>,
53}
54
55/// Configuration for a prompt expansion request.
56#[derive(Debug, Clone)]
57pub struct ExpandConfig {
58    /// Diffusion model family (e.g. "flux", "sd15", "sdxl").
59    pub model_family: String,
60    /// Resolved generation task and conditioning policy.
61    pub task: ExpandTask,
62    /// Internal transform mode. Ordinary expansion remains the default.
63    pub operation: PromptTransformOperation,
64    /// Resolved, task-safe dimensions used only for Remix.
65    pub remix_dimensions: Vec<RemixDimension>,
66    /// Number of prompt variations to generate (1 = single expansion).
67    pub variations: usize,
68    /// Sampling temperature (0.0-2.0). Higher = more creative.
69    pub temperature: f64,
70    /// Nucleus sampling threshold.
71    pub top_p: f64,
72    /// Maximum tokens for the LLM response.
73    pub max_tokens: u32,
74    /// Enable Qwen3 thinking mode for higher quality (slower).
75    pub thinking: bool,
76    /// Custom single-expansion system prompt template (overrides built-in).
77    /// Placeholders: `{WORD_LIMIT}`, `{MODEL_NOTES}`
78    pub system_prompt: Option<String>,
79    /// Custom batch-variation system prompt template (overrides built-in).
80    /// Placeholders: `{N}`, `{WORD_LIMIT}`, `{MODEL_NOTES}`
81    pub batch_prompt: Option<String>,
82    /// Per-family overrides for word limits and style notes.
83    pub family_overrides: HashMap<String, FamilyOverride>,
84    /// Optional visual style to weave into the expansion (per-request, set by
85    /// the route handler from `ExpandRequest::style` — never from settings).
86    pub style: Option<String>,
87}
88
89impl Default for ExpandConfig {
90    fn default() -> Self {
91        Self {
92            model_family: "flux".to_string(),
93            task: ExpandTask::TextToImage,
94            operation: PromptTransformOperation::Expand,
95            remix_dimensions: Vec::new(),
96            variations: 1,
97            temperature: 0.7,
98            top_p: 0.9,
99            max_tokens: 300,
100            thinking: false,
101            system_prompt: None,
102            batch_prompt: None,
103            family_overrides: HashMap::new(),
104            style: None,
105        }
106    }
107}
108
109/// Resolve a Remix request's dimensions without silently weakening source
110/// authority. Explicit unsafe dimensions are rejected; an omitted list uses a
111/// task-aware default. A locked style is never treated as variable.
112pub fn resolve_remix_dimensions(
113    requested: &[RemixDimension],
114    task: ExpandTask,
115    style_locked: bool,
116) -> Result<Vec<RemixDimension>> {
117    let allowed: &[RemixDimension] = match task {
118        ExpandTask::TextToImage | ExpandTask::TextToVideo => &[
119            RemixDimension::Composition,
120            RemixDimension::Camera,
121            RemixDimension::Lighting,
122            RemixDimension::Setting,
123            RemixDimension::Mood,
124            RemixDimension::Movement,
125            RemixDimension::Style,
126        ],
127        ExpandTask::ImageToVideo
128        | ExpandTask::VideoToVideo
129        | ExpandTask::Retake
130        | ExpandTask::KeyframeInterpolation
131        | ExpandTask::AudioDrivenVideo
132        | ExpandTask::ReferenceToAudioVideo => &[RemixDimension::Movement],
133        ExpandTask::TextToAudio => &[RemixDimension::Mood, RemixDimension::Movement],
134    };
135    let candidates = if requested.is_empty() {
136        allowed
137            .iter()
138            .copied()
139            .filter(|dimension| !(style_locked && *dimension == RemixDimension::Style))
140            .collect::<Vec<_>>()
141    } else {
142        requested.to_vec()
143    };
144    let mut resolved = Vec::new();
145    for dimension in candidates {
146        anyhow::ensure!(
147            allowed.contains(&dimension),
148            "remix dimension '{dimension}' conflicts with the {task} conditioning authority"
149        );
150        anyhow::ensure!(
151            !(style_locked && dimension == RemixDimension::Style),
152            "remix dimension 'style' cannot vary while a locked style constraint is set"
153        );
154        if !resolved.contains(&dimension) {
155            resolved.push(dimension);
156        }
157    }
158    anyhow::ensure!(
159        !resolved.is_empty(),
160        "remix requires at least one safe dimension"
161    );
162    Ok(resolved)
163}
164
165/// Deterministic one-dimension assignment for a one-based logical variation.
166pub fn remix_dimensions_for_position(
167    dimensions: &[RemixDimension],
168    position: usize,
169) -> Vec<RemixDimension> {
170    dimensions
171        .get(position.saturating_sub(1) % dimensions.len().max(1))
172        .copied()
173        .into_iter()
174        .collect()
175}
176
177/// Result of a prompt expansion.
178#[derive(Debug, Clone)]
179pub struct ExpandResult {
180    /// The original user prompt.
181    pub original: String,
182    /// Expanded prompt(s). Length equals `ExpandConfig::variations`.
183    pub expanded: Vec<String>,
184}
185
186/// Trait for prompt expansion backends.
187pub trait PromptExpander: Send + Sync {
188    /// Expand a user prompt into one or more detailed image generation prompts.
189    fn expand(&self, prompt: &str, config: &ExpandConfig) -> Result<ExpandResult>;
190}
191
192/// Validate the size of one reviewed expansion set before any model or network
193/// work begins.
194pub fn validate_expansion_variation_count(variations: usize) -> Result<()> {
195    anyhow::ensure!(variations > 0, "variations must be at least 1");
196    anyhow::ensure!(
197        variations <= MAX_EXPANSION_VARIATIONS,
198        "variations exceeds the per-request safety limit of {}",
199        MAX_EXPANSION_VARIATIONS
200    );
201    Ok(())
202}
203
204/// Generate an exact logical expansion from bounded backend completions.
205///
206/// `generate` receives a cloned config whose `variations` and `max_tokens` are
207/// scoped to one attempt. The configured token allowance is per prompt, rather
208/// than one fixed allowance shared by an arbitrarily large batch.
209pub fn expand_exact_with<F>(config: &ExpandConfig, mut generate: F) -> Result<Vec<String>>
210where
211    F: FnMut(&ExpandConfig, ExpansionAttempt) -> Result<Vec<String>>,
212{
213    validate_expansion_variation_count(config.variations)?;
214
215    // Never reserve the caller-controlled logical count up front. Only the
216    // active completion chunk has bounded allocation.
217    let mut expanded = Vec::new();
218    let mut normalized = HashSet::new();
219    while expanded.len() < config.variations {
220        let chunk_target = (config.variations - expanded.len()).min(EXPANSION_CHUNK_SIZE);
221        let mut chunk = Vec::with_capacity(chunk_target);
222
223        for _ in 0..EXPANSION_CHUNK_ATTEMPTS {
224            let missing = chunk_target - chunk.len();
225            if missing == 0 {
226                break;
227            }
228
229            let mut attempt_config = config.clone();
230            attempt_config.variations = missing;
231            attempt_config.max_tokens = config.max_tokens.saturating_mul(missing as u32);
232
233            let attempt_context = ExpansionAttempt {
234                start: expanded.len() + chunk.len() + 1,
235                total: config.variations,
236            };
237            let attempt = generate(&attempt_config, attempt_context)?;
238            if attempt.len() > missing {
239                anyhow::bail!(
240                    "expansion backend returned {} prompts when exactly {missing} were requested",
241                    attempt.len()
242                );
243            }
244            for prompt in attempt {
245                let key = normalize_expanded_prompt(&prompt);
246                if !key.is_empty() && normalized.insert(key) {
247                    chunk.push(prompt);
248                }
249            }
250        }
251
252        if chunk.len() != chunk_target {
253            anyhow::bail!(
254                "expected exactly {} distinct non-empty prompts, but the expansion backend returned {}",
255                config.variations,
256                expanded.len() + chunk.len()
257            );
258        }
259        expanded.extend(chunk);
260    }
261
262    debug_assert_eq!(expanded.len(), config.variations);
263    Ok(expanded)
264}
265
266/// Validate an expansion received across a protocol boundary.
267///
268/// New expanders enforce this internally; clients use it defensively when
269/// talking to older or third-party hosts.
270pub fn validate_expanded_prompts(prompts: &[String], expected: usize) -> Result<()> {
271    let distinct: HashSet<String> = prompts
272        .iter()
273        .map(|prompt| normalize_expanded_prompt(prompt))
274        .filter(|prompt| !prompt.is_empty())
275        .collect();
276    anyhow::ensure!(
277        prompts.len() == expected && distinct.len() == expected,
278        "Expected exactly {expected} distinct non-empty prompts, but the host returned {}",
279        distinct.len()
280    );
281    Ok(())
282}
283
284fn normalize_expanded_prompt(prompt: &str) -> String {
285    prompt
286        .chars()
287        .flat_map(char::to_lowercase)
288        .filter(|character| character.is_alphanumeric())
289        .collect::<String>()
290}
291
292// ── API expander ─────────────────────────────────────────────────────────────
293
294/// OpenAI-compatible chat completion message.
295#[derive(Debug, Serialize, Deserialize)]
296struct ChatMessage {
297    role: String,
298    content: String,
299}
300
301/// Request body for `/v1/chat/completions`.
302#[derive(Debug, Serialize)]
303struct ChatCompletionRequest {
304    model: String,
305    messages: Vec<ChatMessage>,
306    temperature: f64,
307    top_p: f64,
308    max_tokens: u32,
309    /// Qwen3/vLLM thinking mode — only serialized when `true`.
310    /// NOTE: this is a non-standard extension; strict OpenAI-compatible
311    /// endpoints may reject it. Only enable when the backend supports it
312    /// (e.g. Ollama, vLLM with Qwen3 models).
313    #[serde(skip_serializing_if = "std::ops::Not::not")]
314    enable_thinking: bool,
315}
316
317/// Response from `/v1/chat/completions`.
318#[derive(Debug, Deserialize)]
319struct ChatCompletionResponse {
320    choices: Vec<ChatChoice>,
321}
322
323#[derive(Debug, Deserialize)]
324struct ChatChoice {
325    message: ChatMessageResponse,
326}
327
328#[derive(Debug, Deserialize)]
329struct ChatMessageResponse {
330    content: String,
331}
332
333/// Expander that calls an OpenAI-compatible API endpoint.
334pub struct ApiExpander {
335    endpoint: String,
336    model: String,
337}
338
339impl ApiExpander {
340    pub fn new(endpoint: &str, model: &str) -> Self {
341        // Strip trailing slash for consistent URL building
342        let endpoint = endpoint.trim_end_matches('/').to_string();
343        Self {
344            endpoint,
345            model: model.to_string(),
346        }
347    }
348}
349
350impl PromptExpander for ApiExpander {
351    fn expand(&self, prompt: &str, config: &ExpandConfig) -> Result<ExpandResult> {
352        let expanded = expand_exact_with(config, |attempt_config, attempt| {
353            let family_override = attempt_config
354                .family_overrides
355                .get(&attempt_config.model_family);
356            let messages = if attempt_config.operation == PromptTransformOperation::Remix {
357                build_remix_messages_with_context_for_task(
358                    prompt,
359                    &attempt_config.model_family,
360                    attempt_config.variations,
361                    attempt_config.task,
362                    Some((attempt.start, attempt.total)),
363                    family_override,
364                    attempt_config.style.as_deref(),
365                    &attempt_config.remix_dimensions,
366                )
367            } else if attempt.total > 1 {
368                build_batch_messages_with_context_for_task(
369                    prompt,
370                    &attempt_config.model_family,
371                    attempt_config.variations,
372                    attempt_config.task,
373                    Some((attempt.start, attempt.total)),
374                    attempt_config.batch_prompt.as_deref(),
375                    family_override,
376                    attempt_config.style.as_deref(),
377                )
378            } else {
379                build_single_messages_for_task(
380                    prompt,
381                    &attempt_config.model_family,
382                    attempt_config.task,
383                    attempt_config.system_prompt.as_deref(),
384                    family_override,
385                    attempt_config.style.as_deref(),
386                )
387            };
388
389            let chat_messages: Vec<ChatMessage> = messages
390                .into_iter()
391                .map(|(role, content)| ChatMessage { role, content })
392                .collect();
393
394            let req_body = ChatCompletionRequest {
395                model: self.model.clone(),
396                messages: chat_messages,
397                temperature: attempt_config.temperature,
398                top_p: attempt_config.top_p,
399                max_tokens: attempt_config.max_tokens,
400                enable_thinking: attempt_config.thinking,
401            };
402
403            let url = format!("{}/v1/chat/completions", self.endpoint);
404
405            // Use ureq (blocking HTTP) — this trait method is sync and may be
406            // called from within a tokio runtime via spawn_blocking, so we cannot
407            // use async reqwest or Handle::block_on (which panics inside a runtime).
408            let body = serde_json::to_string(&req_body)?;
409            let response_text: String = ureq::post(&url)
410                .header("Content-Type", "application/json")
411                .send(body.as_str())
412                .map_err(|e| anyhow::anyhow!("expand API request failed: {e}"))?
413                .body_mut()
414                .read_to_string()
415                .map_err(|e| anyhow::anyhow!("failed to read expand API response: {e}"))?;
416
417            let completion: ChatCompletionResponse = serde_json::from_str(&response_text)
418                .map_err(|e| anyhow::anyhow!("failed to parse expand API response: {e}"))?;
419
420            let content = completion
421                .choices
422                .first()
423                .map(|c| c.message.content.clone())
424                .filter(|c| !c.trim().is_empty())
425                .ok_or_else(|| {
426                    anyhow::anyhow!(
427                        "expand API returned empty response (no choices or empty content)"
428                    )
429                })?;
430
431            Ok(
432                if attempt_config.operation == PromptTransformOperation::Remix || attempt.total > 1
433                {
434                    parse_variations(&content, attempt_config.variations)
435                } else {
436                    vec![clean_expanded_prompt(&content)]
437                },
438            )
439        })?;
440
441        Ok(ExpandResult {
442            original: prompt.to_string(),
443            expanded,
444        })
445    }
446}
447
448/// Public wrapper for `parse_variations` (used by mold-inference local expander).
449pub fn parse_variations_public(text: &str, expected: usize) -> Vec<String> {
450    parse_variations(text, expected)
451}
452
453/// Public wrapper for `clean_expanded_prompt` (used by mold-inference local expander).
454pub fn clean_expanded_prompt_public(text: &str) -> String {
455    clean_expanded_prompt(text)
456}
457
458/// Parse multiple variations from LLM output.
459/// Tries JSON array first, then numbered list, then line-separated.
460fn parse_variations(text: &str, expected: usize) -> Vec<String> {
461    let trimmed = text.trim();
462
463    // Try JSON array
464    if let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed) {
465        if !arr.is_empty() {
466            return arr.into_iter().map(|s| clean_expanded_prompt(&s)).collect();
467        }
468    }
469
470    // Try to find a JSON array embedded in the text (LLM may include preamble)
471    if let Some(start) = trimmed.find('[') {
472        if let Some(end) = trimmed.rfind(']') {
473            if start < end {
474                let json_slice = &trimmed[start..=end];
475                if let Ok(arr) = serde_json::from_str::<Vec<String>>(json_slice) {
476                    if !arr.is_empty() {
477                        return arr.into_iter().map(|s| clean_expanded_prompt(&s)).collect();
478                    }
479                }
480            }
481        }
482    }
483
484    // Fall back to numbered list parsing (1. ... 2. ... etc.)
485    let lines: Vec<String> = trimmed
486        .lines()
487        .map(|l| l.trim())
488        .filter(|l| !l.is_empty())
489        .map(|l| {
490            // Strip numbered prefix: "1. ", "2) ", etc.
491            let stripped = l
492                .trim_start_matches(|c: char| c.is_ascii_digit())
493                .trim_start_matches(['.', ')', ':', '-'])
494                .trim_start_matches('"')
495                .trim_end_matches('"')
496                .trim();
497            clean_expanded_prompt(stripped)
498        })
499        .filter(|l| !l.is_empty())
500        .collect();
501
502    if lines.len() >= expected {
503        return lines;
504    }
505
506    // Last resort: split on double newlines
507    let paragraphs: Vec<String> = trimmed
508        .split("\n\n")
509        .map(|p| clean_expanded_prompt(p.trim()))
510        .filter(|p| !p.is_empty())
511        .collect();
512
513    if !paragraphs.is_empty() {
514        return paragraphs;
515    }
516
517    // Ultimate fallback: return the whole text as a single variation
518    vec![clean_expanded_prompt(trimmed)]
519}
520
521/// Clean up an expanded prompt: trim whitespace, remove quotes, collapse whitespace.
522fn clean_expanded_prompt(text: &str) -> String {
523    // Some OpenAI-compatible/local backends emit one JSON array per requested
524    // variation (`["prompt"]\n["prompt"]`) instead of one shared array. The
525    // line fallback feeds each singleton here, so unwrap it before ordinary
526    // quote/whitespace cleanup. Never flatten a real multi-item array.
527    let singleton = serde_json::from_str::<Vec<String>>(text.trim())
528        .ok()
529        .and_then(|items| (items.len() == 1).then(|| items.into_iter().next().unwrap()));
530    let trimmed = singleton
531        .as_deref()
532        .unwrap_or(text)
533        .trim()
534        .trim_matches('"')
535        .trim_matches('\'')
536        .trim();
537
538    // Strip any thinking block if present
539    let cleaned = if let Some(end_idx) = trimmed.find("</think>") {
540        trimmed[end_idx + "</think>".len()..].trim()
541    } else {
542        trimmed
543    };
544
545    // Collapse multiple whitespace/newlines into single spaces
546    cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
547}
548
549/// Expand configuration from the mold config file.
550#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct ExpandSettings {
552    /// Enable prompt expansion by default (overridden by --expand/--no-expand).
553    #[serde(default)]
554    pub enabled: bool,
555    /// Backend: "local" for built-in GGUF inference, or a URL for OpenAI-compatible API.
556    #[serde(default = "default_backend")]
557    pub backend: String,
558    /// Model name for local GGUF expansion.
559    #[serde(default = "default_expand_model")]
560    pub model: String,
561    /// Model name when using API backend (e.g. "qwen2.5:3b" for Ollama).
562    #[serde(default = "default_api_model")]
563    pub api_model: String,
564    /// Sampling temperature.
565    #[serde(default = "default_temperature")]
566    pub temperature: f64,
567    /// Nucleus sampling threshold.
568    #[serde(default = "default_top_p")]
569    pub top_p: f64,
570    /// Maximum tokens for the LLM response.
571    #[serde(default = "default_max_tokens")]
572    pub max_tokens: u32,
573    /// Enable thinking mode for Qwen3 (higher quality, slower).
574    #[serde(default)]
575    pub thinking: bool,
576    /// Custom single-expansion system prompt template.
577    /// Available placeholders: `{WORD_LIMIT}`, `{MODEL_NOTES}`
578    #[serde(default)]
579    pub system_prompt: Option<String>,
580    /// Custom batch-variation system prompt template.
581    /// Available placeholders: `{N}`, `{WORD_LIMIT}`, `{MODEL_NOTES}`
582    #[serde(default)]
583    pub batch_prompt: Option<String>,
584    /// Per-family word limit and style notes overrides.
585    #[serde(default)]
586    pub families: HashMap<String, FamilyOverride>,
587}
588
589fn default_backend() -> String {
590    "local".to_string()
591}
592
593fn default_expand_model() -> String {
594    "qwen3-expand:q8".to_string()
595}
596
597fn default_api_model() -> String {
598    "qwen2.5:3b".to_string()
599}
600
601fn default_temperature() -> f64 {
602    0.7
603}
604
605fn default_top_p() -> f64 {
606    0.9
607}
608
609fn default_max_tokens() -> u32 {
610    300
611}
612
613impl Default for ExpandSettings {
614    fn default() -> Self {
615        Self {
616            enabled: false,
617            backend: default_backend(),
618            model: default_expand_model(),
619            api_model: default_api_model(),
620            temperature: default_temperature(),
621            top_p: default_top_p(),
622            max_tokens: default_max_tokens(),
623            thinking: false,
624            system_prompt: None,
625            batch_prompt: None,
626            families: HashMap::new(),
627        }
628    }
629}
630
631impl ExpandSettings {
632    /// Load from environment variables, falling back to provided defaults.
633    pub fn with_env_overrides(mut self) -> Self {
634        if let Ok(v) = std::env::var("MOLD_EXPAND") {
635            self.enabled = matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes");
636        }
637        if let Ok(v) = std::env::var("MOLD_EXPAND_BACKEND") {
638            if !v.is_empty() {
639                self.backend = v;
640            }
641        }
642        if let Ok(v) = std::env::var("MOLD_EXPAND_MODEL") {
643            if !v.is_empty() {
644                if self.is_local() {
645                    self.model = v;
646                } else {
647                    self.api_model = v;
648                }
649            }
650        }
651        if let Ok(v) = std::env::var("MOLD_EXPAND_TEMPERATURE") {
652            if let Ok(t) = v.parse::<f64>() {
653                self.temperature = t;
654            }
655        }
656        if let Ok(v) = std::env::var("MOLD_EXPAND_THINKING") {
657            self.thinking = matches!(v.trim().to_lowercase().as_str(), "1" | "true" | "yes");
658        }
659        if let Ok(v) = std::env::var("MOLD_EXPAND_SYSTEM_PROMPT") {
660            if !v.is_empty() {
661                self.system_prompt = Some(v);
662            }
663        }
664        if let Ok(v) = std::env::var("MOLD_EXPAND_BATCH_PROMPT") {
665            if !v.is_empty() {
666                self.batch_prompt = Some(v);
667            }
668        }
669        self
670    }
671
672    /// Build an `ExpandConfig` for a specific request.
673    pub fn to_expand_config(&self, model_family: &str, variations: usize) -> ExpandConfig {
674        ExpandConfig {
675            model_family: model_family.to_string(),
676            task: ExpandTask::for_family(model_family),
677            operation: PromptTransformOperation::Expand,
678            remix_dimensions: Vec::new(),
679            variations,
680            temperature: self.temperature,
681            top_p: self.top_p,
682            max_tokens: self.max_tokens,
683            thinking: self.thinking,
684            system_prompt: self.system_prompt.clone(),
685            batch_prompt: self.batch_prompt.clone(),
686            family_overrides: self.families.clone(),
687            // Style is per-request state; the route handler sets it from the
688            // incoming ExpandRequest, never from persisted settings.
689            style: None,
690        }
691    }
692
693    /// Validate that custom templates contain expected placeholders.
694    /// Returns a list of warnings (empty = valid). Callers should treat
695    /// these as non-fatal hints — expansion still runs with partial templates.
696    pub fn validate_templates(&self) -> Vec<String> {
697        let mut warnings = Vec::new();
698        if let Some(ref tmpl) = self.system_prompt {
699            for placeholder in ["{WORD_LIMIT}", "{MODEL_NOTES}"] {
700                if !tmpl.contains(placeholder) {
701                    warnings.push(format!(
702                        "system_prompt is missing placeholder {placeholder} — it won't be substituted"
703                    ));
704                }
705            }
706        }
707        if let Some(ref tmpl) = self.batch_prompt {
708            for placeholder in ["{N}", "{WORD_LIMIT}", "{MODEL_NOTES}"] {
709                if !tmpl.contains(placeholder) {
710                    warnings.push(format!(
711                        "batch_prompt is missing placeholder {placeholder} — it won't be substituted"
712                    ));
713                }
714            }
715        }
716        warnings
717    }
718
719    /// Model identity selected by the configured backend.
720    pub fn active_model(&self) -> &str {
721        if self.is_local() {
722            &self.model
723        } else {
724            &self.api_model
725        }
726    }
727
728    /// Create the appropriate expander backend after enforcing model access.
729    /// Returns `None` if the backend is "local" (handled by mold-inference).
730    pub fn create_api_expander(&self) -> Result<Option<ApiExpander>, crate::ModelActivationError> {
731        crate::require_model_activation(self.active_model(), None)?;
732        Ok(if self.is_local() {
733            None
734        } else {
735            Some(ApiExpander::new(&self.backend, &self.api_model))
736        })
737    }
738
739    /// Check if this is configured for local (GGUF) expansion.
740    pub fn is_local(&self) -> bool {
741        self.backend == "local"
742    }
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::expand_prompts::build_batch_messages_with_context;
749
750    // ── clean_expanded_prompt ────────────────────────────────────────────
751
752    #[test]
753    fn clean_prompt_strips_quotes() {
754        assert_eq!(clean_expanded_prompt("\"a cat on mars\""), "a cat on mars");
755    }
756
757    #[test]
758    fn clean_prompt_strips_single_quotes() {
759        assert_eq!(clean_expanded_prompt("'a cat on mars'"), "a cat on mars");
760    }
761
762    #[test]
763    fn clean_prompt_unwraps_singleton_json_array() {
764        assert_eq!(
765            clean_expanded_prompt(r#"["a cat on mars"]"#),
766            "a cat on mars"
767        );
768    }
769
770    #[test]
771    fn clean_prompt_strips_thinking() {
772        let input = "<think>hmm let me think</think>\n\na cat on mars";
773        assert_eq!(clean_expanded_prompt(input), "a cat on mars");
774    }
775
776    #[test]
777    fn clean_prompt_strips_multiline_thinking() {
778        let input = "<think>\nstep 1: analyze\nstep 2: expand\n</think>\n\ndetailed prompt here";
779        assert_eq!(clean_expanded_prompt(input), "detailed prompt here");
780    }
781
782    #[test]
783    fn clean_prompt_collapses_whitespace() {
784        assert_eq!(
785            clean_expanded_prompt("a  cat\n\non   mars"),
786            "a cat on mars"
787        );
788    }
789
790    #[test]
791    fn clean_prompt_empty_input() {
792        assert_eq!(clean_expanded_prompt(""), "");
793        assert_eq!(clean_expanded_prompt("   "), "");
794    }
795
796    #[test]
797    fn clean_prompt_only_thinking_block() {
798        let input = "<think>some reasoning</think>";
799        assert_eq!(clean_expanded_prompt(input), "");
800    }
801
802    #[test]
803    fn clean_prompt_preserves_content_without_thinking() {
804        let input = "a beautiful sunset over the ocean, golden light, dramatic clouds";
805        assert_eq!(clean_expanded_prompt(input), input);
806    }
807
808    // ── parse_variations ─────────────────────────────────────────────────
809
810    #[test]
811    fn parse_variations_json_array() {
812        let input = r#"["a cat", "a dog", "a bird"]"#;
813        let result = parse_variations(input, 3);
814        assert_eq!(result, vec!["a cat", "a dog", "a bird"]);
815    }
816
817    #[test]
818    fn parse_variations_embedded_json() {
819        let input = "Here are 3 prompts:\n[\"a cat\", \"a dog\", \"a bird\"]";
820        let result = parse_variations(input, 3);
821        assert_eq!(result, vec!["a cat", "a dog", "a bird"]);
822    }
823
824    #[test]
825    fn parse_variations_json_with_thinking() {
826        let input =
827            "<think>let me think</think>\n\n[\"expanded cat\", \"expanded dog\", \"expanded bird\"]";
828        // The thinking block is inside individual items, not wrapping the JSON.
829        // parse_variations should find the embedded JSON array.
830        let result = parse_variations(input, 3);
831        assert_eq!(result.len(), 3);
832    }
833
834    #[test]
835    fn parse_variations_numbered_list() {
836        let input = "1. a cat on mars\n2. a dog in space\n3. a bird underwater";
837        let result = parse_variations(input, 3);
838        assert_eq!(result.len(), 3);
839        assert!(result[0].contains("cat"));
840        assert!(result[1].contains("dog"));
841        assert!(result[2].contains("bird"));
842    }
843
844    #[test]
845    fn parse_variations_numbered_with_parens() {
846        let input = "1) a cat\n2) a dog\n3) a bird";
847        let result = parse_variations(input, 3);
848        assert_eq!(result.len(), 3);
849        assert!(result[0].contains("cat"));
850    }
851
852    #[test]
853    fn parse_variations_numbered_with_quotes() {
854        let input = "1. \"a cat on mars\"\n2. \"a dog in space\"";
855        let result = parse_variations(input, 2);
856        assert_eq!(result.len(), 2);
857        // Quotes should be stripped by clean_expanded_prompt
858        assert!(!result[0].starts_with('"'));
859        assert!(result[0].contains("cat"));
860    }
861
862    #[test]
863    fn parse_variations_paragraph_fallback() {
864        let input = "A majestic cat sitting on mars\n\nA playful dog floating in space";
865        let result = parse_variations(input, 2);
866        assert_eq!(result.len(), 2);
867        assert!(result[0].contains("cat"));
868        assert!(result[1].contains("dog"));
869    }
870
871    #[test]
872    fn parse_variations_single_text_fallback() {
873        // When nothing else matches, return the whole text as one variation
874        let input = "just a single prompt with no structure";
875        let result = parse_variations(input, 3);
876        assert!(!result.is_empty());
877        assert!(result[0].contains("single prompt"));
878    }
879
880    #[test]
881    fn parse_variations_empty_json_array_falls_through() {
882        // Empty JSON array should fall through to other parsers
883        let input = "[]";
884        let result = parse_variations(input, 3);
885        // Should not panic; falls through to numbered list / paragraph / fallback
886        assert!(!result.is_empty());
887    }
888
889    #[test]
890    fn parse_variations_cleans_each_item() {
891        let input = r#"["  a cat  ", "  a dog  "]"#;
892        let result = parse_variations(input, 2);
893        assert_eq!(result[0], "a cat");
894        assert_eq!(result[1], "a dog");
895    }
896
897    #[test]
898    fn parse_variations_repeated_singleton_json_arrays() {
899        let input = "[\"a cat\"]\n[\"a dog\"]\n[\"a bird\"]";
900        let result = parse_variations(input, 3);
901        assert_eq!(result, vec!["a cat", "a dog", "a bird"]);
902    }
903
904    // ── bounded exact expansion ─────────────────────────────────────────
905
906    #[test]
907    fn exact_expansion_chunks_large_batches_and_scales_token_budget() {
908        let config = ExpandConfig {
909            variations: 10,
910            max_tokens: 300,
911            ..Default::default()
912        };
913        let mut attempts = Vec::new();
914
915        let expanded = expand_exact_with(&config, |attempt, context| {
916            attempts.push((
917                attempt.variations,
918                attempt.max_tokens,
919                context.start,
920                context.total,
921            ));
922            Ok((0..attempt.variations)
923                .map(|index| format!("prompt {}", context.start + index))
924                .collect())
925        })
926        .unwrap();
927
928        assert_eq!(expanded.len(), 10);
929        assert_eq!(
930            attempts,
931            vec![(4, 1200, 1, 10), (4, 1200, 5, 10), (2, 600, 9, 10)]
932        );
933    }
934
935    #[test]
936    fn exact_expansion_retries_only_missing_prompts() {
937        let config = ExpandConfig {
938            variations: 8,
939            ..Default::default()
940        };
941        let mut requested = Vec::new();
942
943        let expanded = expand_exact_with(&config, |attempt, context| {
944            requested.push(attempt.variations);
945            let returned = match requested.as_slice() {
946                [4] => 3,
947                [4, 1] => {
948                    let messages = build_batch_messages_with_context(
949                        "source",
950                        "flux",
951                        attempt.variations,
952                        Some((context.start, context.total)),
953                        None,
954                        None,
955                        None,
956                    );
957                    assert!(messages[0].1.contains("variations 4 through 4 of 8"));
958                    1
959                }
960                _ => attempt.variations,
961            };
962            Ok((0..returned)
963                .map(|index| format!("prompt {}", context.start + index))
964                .collect())
965        })
966        .unwrap();
967
968        assert_eq!(expanded.len(), 8);
969        assert_eq!(requested, vec![4, 1, 4]);
970    }
971
972    #[test]
973    fn exact_expansion_fails_after_bounded_partial_attempts() {
974        let config = ExpandConfig {
975            variations: 8,
976            ..Default::default()
977        };
978        let mut attempts = 0;
979
980        let error = expand_exact_with(&config, |_, _| {
981            attempts += 1;
982            Ok(vec!["only one".to_string()])
983        })
984        .unwrap_err();
985
986        assert_eq!(attempts, EXPANSION_CHUNK_ATTEMPTS);
987        assert!(
988            error
989                .to_string()
990                .contains("expected exactly 8 distinct non-empty prompts"),
991            "{error}"
992        );
993    }
994
995    #[test]
996    fn exact_expansion_rejects_zero_variations() {
997        let config = ExpandConfig {
998            variations: 0,
999            ..Default::default()
1000        };
1001        let error = expand_exact_with(&config, |_, _| unreachable!()).unwrap_err();
1002        assert!(error.to_string().contains("at least 1"));
1003    }
1004
1005    #[test]
1006    fn exact_expansion_rejects_counts_above_the_safety_limit_without_allocating() {
1007        let config = ExpandConfig {
1008            variations: MAX_EXPANSION_VARIATIONS + 1,
1009            ..Default::default()
1010        };
1011        let error = expand_exact_with(&config, |_, _| unreachable!()).unwrap_err();
1012        assert!(error.to_string().contains("safety limit"), "{error}");
1013    }
1014
1015    #[test]
1016    fn exact_expansion_rejects_excess_results_instead_of_truncating() {
1017        let config = ExpandConfig {
1018            variations: 2,
1019            ..Default::default()
1020        };
1021        let error = expand_exact_with(&config, |_, _| {
1022            Ok(vec!["one".into(), "two".into(), "three".into()])
1023        })
1024        .unwrap_err();
1025        assert!(error.to_string().contains("exactly 2 were requested"));
1026    }
1027
1028    #[test]
1029    fn exact_expansion_retries_duplicates_from_prior_chunks() {
1030        let config = ExpandConfig {
1031            variations: 6,
1032            ..Default::default()
1033        };
1034        let mut attempts = 0;
1035
1036        let expanded = expand_exact_with(&config, |attempt, context| {
1037            attempts += 1;
1038            if context.start == 5 && attempts == 2 {
1039                Ok(vec!["prompt 1".into(), "prompt 5".into()])
1040            } else {
1041                Ok((0..attempt.variations)
1042                    .map(|index| format!("prompt {}", context.start + index))
1043                    .collect())
1044            }
1045        })
1046        .unwrap();
1047
1048        assert_eq!(expanded.len(), 6);
1049        assert_eq!(expanded[4], "prompt 5");
1050        assert_eq!(expanded[5], "prompt 6");
1051        assert_eq!(attempts, 3);
1052    }
1053
1054    #[test]
1055    fn protocol_validation_rejects_duplicate_or_empty_prompts() {
1056        let duplicate = vec!["A cat".into(), " a  cat! ".into()];
1057        let error = validate_expanded_prompts(&duplicate, 2).unwrap_err();
1058        assert!(error.to_string().contains("returned 1"), "{error}");
1059
1060        let empty = vec!["A cat".into(), "   ".into()];
1061        let error = validate_expanded_prompts(&empty, 2).unwrap_err();
1062        assert!(error.to_string().contains("returned 1"), "{error}");
1063    }
1064
1065    // ── ExpandSettings ───────────────────────────────────────────────────
1066
1067    #[test]
1068    fn expand_settings_defaults() {
1069        let settings = ExpandSettings::default();
1070        assert!(!settings.enabled);
1071        assert_eq!(settings.backend, "local");
1072        assert_eq!(settings.model, "qwen3-expand:q8");
1073        assert_eq!(settings.api_model, "qwen2.5:3b");
1074        assert_eq!(settings.temperature, 0.7);
1075        assert_eq!(settings.top_p, 0.9);
1076        assert_eq!(settings.max_tokens, 300);
1077        assert!(!settings.thinking);
1078        assert!(settings.system_prompt.is_none());
1079        assert!(settings.batch_prompt.is_none());
1080        assert!(settings.families.is_empty());
1081    }
1082
1083    #[test]
1084    fn expand_settings_is_local() {
1085        let settings = ExpandSettings::default();
1086        assert!(settings.is_local());
1087
1088        let api_settings = ExpandSettings {
1089            backend: "http://localhost:11434".to_string(),
1090            ..Default::default()
1091        };
1092        assert!(!api_settings.is_local());
1093    }
1094
1095    #[test]
1096    fn expand_settings_create_api_expander_none_for_local() {
1097        let settings = ExpandSettings::default();
1098        assert!(settings.create_api_expander().unwrap().is_none());
1099    }
1100
1101    #[test]
1102    fn expand_settings_create_api_expander_some_for_url() {
1103        let settings = ExpandSettings {
1104            backend: "http://localhost:11434".to_string(),
1105            api_model: "llama3:8b".to_string(),
1106            ..Default::default()
1107        };
1108        let expander = settings.create_api_expander().unwrap();
1109        assert!(expander.is_some());
1110    }
1111
1112    #[test]
1113    fn expand_settings_gate_the_selected_local_or_api_model() {
1114        let local_h3 = ExpandSettings {
1115            model: "MiniMax-H3".to_string(),
1116            api_model: "ordinary-inactive-api-model".to_string(),
1117            ..Default::default()
1118        };
1119        let error = local_h3
1120            .create_api_expander()
1121            .err()
1122            .expect("the selected local H3 model must be gated");
1123        assert!(error
1124            .to_string()
1125            .contains(crate::MINIMAX_H3_AUTHORIZATION_REQUIRED));
1126
1127        let api_h3 = ExpandSettings {
1128            backend: "http://localhost:11434".to_string(),
1129            model: "ordinary-inactive-local-model".to_string(),
1130            api_model: "MiniMaxAI/MiniMax-H3".to_string(),
1131            ..Default::default()
1132        };
1133        assert!(api_h3.create_api_expander().is_err());
1134
1135        let inactive_h3 = ExpandSettings {
1136            backend: "http://localhost:11434".to_string(),
1137            model: "MiniMax-H3".to_string(),
1138            api_model: "llama3:8b".to_string(),
1139            ..Default::default()
1140        };
1141        assert!(inactive_h3.create_api_expander().unwrap().is_some());
1142    }
1143
1144    #[test]
1145    fn expand_settings_to_expand_config() {
1146        let settings = ExpandSettings {
1147            temperature: 0.5,
1148            top_p: 0.8,
1149            max_tokens: 200,
1150            thinking: true,
1151            ..Default::default()
1152        };
1153        let config = settings.to_expand_config("sdxl", 3);
1154        assert_eq!(config.model_family, "sdxl");
1155        assert_eq!(config.variations, 3);
1156        assert_eq!(config.temperature, 0.5);
1157        assert_eq!(config.top_p, 0.8);
1158        assert_eq!(config.max_tokens, 200);
1159        assert!(config.thinking);
1160    }
1161
1162    #[test]
1163    fn expand_settings_serde_roundtrip() {
1164        let mut families = HashMap::new();
1165        families.insert(
1166            "sd15".to_string(),
1167            FamilyOverride {
1168                word_limit: Some(80),
1169                style_notes: Some("Custom SD1.5 notes.".to_string()),
1170            },
1171        );
1172        let settings = ExpandSettings {
1173            enabled: true,
1174            backend: "http://example.com".to_string(),
1175            model: "qwen3-expand-small:q8".to_string(),
1176            api_model: "gpt-4".to_string(),
1177            temperature: 1.2,
1178            top_p: 0.95,
1179            max_tokens: 500,
1180            thinking: true,
1181            system_prompt: Some("Custom system prompt {WORD_LIMIT} {MODEL_NOTES}".to_string()),
1182            batch_prompt: Some("Custom batch {N} {WORD_LIMIT} {MODEL_NOTES}".to_string()),
1183            families,
1184        };
1185        let toml_str = toml::to_string(&settings).unwrap();
1186        let deserialized: ExpandSettings = toml::from_str(&toml_str).unwrap();
1187        assert_eq!(deserialized.enabled, settings.enabled);
1188        assert_eq!(deserialized.backend, settings.backend);
1189        assert_eq!(deserialized.model, settings.model);
1190        assert_eq!(deserialized.api_model, settings.api_model);
1191        assert_eq!(deserialized.temperature, settings.temperature);
1192        assert_eq!(deserialized.max_tokens, settings.max_tokens);
1193        assert_eq!(deserialized.thinking, settings.thinking);
1194        assert_eq!(deserialized.system_prompt, settings.system_prompt);
1195        assert_eq!(deserialized.batch_prompt, settings.batch_prompt);
1196        assert_eq!(deserialized.families.len(), 1);
1197        let sd15 = deserialized.families.get("sd15").unwrap();
1198        assert_eq!(sd15.word_limit, Some(80));
1199        assert_eq!(sd15.style_notes.as_deref(), Some("Custom SD1.5 notes."));
1200    }
1201
1202    #[test]
1203    fn expand_settings_serde_defaults_on_empty() {
1204        // Deserializing an empty table should produce all defaults
1205        let deserialized: ExpandSettings = toml::from_str("").unwrap();
1206        let defaults = ExpandSettings::default();
1207        assert_eq!(deserialized.enabled, defaults.enabled);
1208        assert_eq!(deserialized.backend, defaults.backend);
1209        assert_eq!(deserialized.model, defaults.model);
1210        assert_eq!(deserialized.temperature, defaults.temperature);
1211    }
1212
1213    // ── ApiExpander ──────────────────────────────────────────────────────
1214
1215    #[test]
1216    fn api_expander_strips_trailing_slash() {
1217        let expander = ApiExpander::new("http://localhost:11434/", "qwen2.5:3b");
1218        assert_eq!(expander.endpoint, "http://localhost:11434");
1219    }
1220
1221    #[test]
1222    fn api_expander_no_slash_unchanged() {
1223        let expander = ApiExpander::new("http://localhost:11434", "qwen2.5:3b");
1224        assert_eq!(expander.endpoint, "http://localhost:11434");
1225    }
1226
1227    // ── ExpandConfig ─────────────────────────────────────────────────────
1228
1229    #[test]
1230    fn expand_config_default() {
1231        let config = ExpandConfig::default();
1232        assert_eq!(config.model_family, "flux");
1233        assert_eq!(config.variations, 1);
1234        assert_eq!(config.temperature, 0.7);
1235        assert_eq!(config.max_tokens, 300);
1236        assert!(!config.thinking);
1237    }
1238
1239    // ── env overrides ────────────────────────────────────────────────────
1240    // These tests use a serial approach to avoid env var races.
1241
1242    #[test]
1243    fn env_override_model_routes_to_local() {
1244        // When backend is "local", MOLD_EXPAND_MODEL should set self.model
1245        let settings = ExpandSettings::default();
1246        assert!(settings.is_local());
1247        // We can't safely set env vars in parallel tests, but we can test
1248        // the routing logic directly:
1249        let mut s = settings;
1250        let v = "qwen3-expand-small:q8".to_string();
1251        if s.is_local() {
1252            s.model = v.clone();
1253        } else {
1254            s.api_model = v.clone();
1255        }
1256        assert_eq!(s.model, "qwen3-expand-small:q8");
1257        assert_eq!(s.api_model, "qwen2.5:3b"); // unchanged
1258    }
1259
1260    #[test]
1261    fn env_override_model_routes_to_api() {
1262        // When backend is a URL, MOLD_EXPAND_MODEL should set self.api_model
1263        let mut s = ExpandSettings {
1264            backend: "http://localhost:11434".to_string(),
1265            ..Default::default()
1266        };
1267        assert!(!s.is_local());
1268        let v = "llama3:70b".to_string();
1269        if s.is_local() {
1270            s.model = v.clone();
1271        } else {
1272            s.api_model = v.clone();
1273        }
1274        assert_eq!(s.api_model, "llama3:70b");
1275        assert_eq!(s.model, "qwen3-expand:q8"); // unchanged
1276    }
1277
1278    // ── template overrides in ExpandConfig ───────────────────────────────
1279
1280    #[test]
1281    fn to_expand_config_passes_overrides() {
1282        let mut families = HashMap::new();
1283        families.insert(
1284            "flux".to_string(),
1285            FamilyOverride {
1286                word_limit: Some(200),
1287                style_notes: None,
1288            },
1289        );
1290        let settings = ExpandSettings {
1291            system_prompt: Some("Custom {WORD_LIMIT} {MODEL_NOTES}".to_string()),
1292            batch_prompt: Some("Batch {N} {WORD_LIMIT} {MODEL_NOTES}".to_string()),
1293            families,
1294            ..Default::default()
1295        };
1296        let config = settings.to_expand_config("flux", 3);
1297        assert_eq!(
1298            config.system_prompt.as_deref(),
1299            Some("Custom {WORD_LIMIT} {MODEL_NOTES}")
1300        );
1301        assert_eq!(
1302            config.batch_prompt.as_deref(),
1303            Some("Batch {N} {WORD_LIMIT} {MODEL_NOTES}")
1304        );
1305        assert_eq!(config.family_overrides.len(), 1);
1306        assert_eq!(
1307            config.family_overrides.get("flux").unwrap().word_limit,
1308            Some(200)
1309        );
1310    }
1311
1312    #[test]
1313    fn expand_config_default_has_no_overrides() {
1314        let config = ExpandConfig::default();
1315        assert!(config.system_prompt.is_none());
1316        assert!(config.batch_prompt.is_none());
1317        assert!(config.family_overrides.is_empty());
1318    }
1319
1320    #[test]
1321    fn expand_config_default_style_is_none() {
1322        assert!(ExpandConfig::default().style.is_none());
1323    }
1324
1325    #[test]
1326    fn to_expand_config_never_sets_style() {
1327        // Style is per-request state owned by the route handler, not settings.
1328        let settings = ExpandSettings::default();
1329        let config = settings.to_expand_config("flux", 3);
1330        assert!(config.style.is_none());
1331    }
1332
1333    // ── validate_templates ──────────────────────────────────────────────
1334
1335    #[test]
1336    fn validate_templates_valid() {
1337        let settings = ExpandSettings {
1338            system_prompt: Some("You are a writer. {WORD_LIMIT} words. {MODEL_NOTES}".to_string()),
1339            batch_prompt: Some(
1340                "Generate {N} prompts. {WORD_LIMIT} words. {MODEL_NOTES}".to_string(),
1341            ),
1342            ..Default::default()
1343        };
1344        assert!(settings.validate_templates().is_empty());
1345    }
1346
1347    #[test]
1348    fn validate_templates_none_is_valid() {
1349        let settings = ExpandSettings::default();
1350        assert!(settings.validate_templates().is_empty());
1351    }
1352
1353    #[test]
1354    fn validate_templates_missing_word_limit() {
1355        let settings = ExpandSettings {
1356            system_prompt: Some("You are a writer. {MODEL_NOTES}".to_string()),
1357            ..Default::default()
1358        };
1359        let errors = settings.validate_templates();
1360        assert_eq!(errors.len(), 1);
1361        assert!(errors[0].contains("{WORD_LIMIT}"));
1362    }
1363
1364    #[test]
1365    fn validate_templates_missing_model_notes() {
1366        let settings = ExpandSettings {
1367            system_prompt: Some("You are a writer. {WORD_LIMIT} words.".to_string()),
1368            ..Default::default()
1369        };
1370        let errors = settings.validate_templates();
1371        assert_eq!(errors.len(), 1);
1372        assert!(errors[0].contains("{MODEL_NOTES}"));
1373    }
1374
1375    #[test]
1376    fn validate_templates_batch_missing_n() {
1377        let settings = ExpandSettings {
1378            batch_prompt: Some("Generate prompts. {WORD_LIMIT} {MODEL_NOTES}".to_string()),
1379            ..Default::default()
1380        };
1381        let errors = settings.validate_templates();
1382        assert_eq!(errors.len(), 1);
1383        assert!(errors[0].contains("{N}"));
1384    }
1385
1386    #[test]
1387    fn validate_templates_batch_missing_all() {
1388        let settings = ExpandSettings {
1389            batch_prompt: Some("Generate prompts.".to_string()),
1390            ..Default::default()
1391        };
1392        let errors = settings.validate_templates();
1393        assert_eq!(errors.len(), 3);
1394    }
1395
1396    // ── FamilyOverride serde ────────────────────────────────────────────
1397
1398    #[test]
1399    fn family_override_serde_roundtrip() {
1400        let ov = FamilyOverride {
1401            word_limit: Some(100),
1402            style_notes: Some("Be creative.".to_string()),
1403        };
1404        let json = serde_json::to_string(&ov).unwrap();
1405        let deserialized: FamilyOverride = serde_json::from_str(&json).unwrap();
1406        assert_eq!(deserialized.word_limit, Some(100));
1407        assert_eq!(deserialized.style_notes.as_deref(), Some("Be creative."));
1408    }
1409
1410    #[test]
1411    fn family_override_partial_toml() {
1412        let toml_str = "word_limit = 75\n";
1413        let ov: FamilyOverride = toml::from_str(toml_str).unwrap();
1414        assert_eq!(ov.word_limit, Some(75));
1415        assert!(ov.style_notes.is_none());
1416    }
1417
1418    // ── full config with families in TOML ───────────────────────────────
1419
1420    #[test]
1421    fn expand_settings_toml_with_families() {
1422        let toml_str = r#"
1423enabled = true
1424system_prompt = "Custom prompt. {WORD_LIMIT} words. {MODEL_NOTES}"
1425
1426[families.sd15]
1427word_limit = 40
1428style_notes = "Short keywords only."
1429
1430[families.flux]
1431word_limit = 250
1432"#;
1433        let settings: ExpandSettings = toml::from_str(toml_str).unwrap();
1434        assert!(settings.enabled);
1435        assert!(settings.system_prompt.is_some());
1436        assert_eq!(settings.families.len(), 2);
1437        let sd15 = settings.families.get("sd15").unwrap();
1438        assert_eq!(sd15.word_limit, Some(40));
1439        assert_eq!(sd15.style_notes.as_deref(), Some("Short keywords only."));
1440        let flux = settings.families.get("flux").unwrap();
1441        assert_eq!(flux.word_limit, Some(250));
1442        assert!(flux.style_notes.is_none());
1443    }
1444
1445    #[test]
1446    fn remix_dimensions_are_task_safe_and_deterministic() {
1447        let text = resolve_remix_dimensions(&[], ExpandTask::TextToImage, false).unwrap();
1448        assert!(text.contains(&RemixDimension::Composition));
1449        assert!(text.contains(&RemixDimension::Style));
1450        assert_eq!(
1451            remix_dimensions_for_position(&text, text.len() + 1),
1452            vec![RemixDimension::Composition]
1453        );
1454
1455        let conditioned = resolve_remix_dimensions(&[], ExpandTask::ImageToVideo, false).unwrap();
1456        assert_eq!(conditioned, vec![RemixDimension::Movement]);
1457        let error = resolve_remix_dimensions(
1458            &[RemixDimension::Composition],
1459            ExpandTask::ImageToVideo,
1460            false,
1461        )
1462        .unwrap_err();
1463        assert!(error.to_string().contains("conditioning authority"));
1464    }
1465
1466    #[test]
1467    fn locked_style_cannot_be_a_remix_dimension() {
1468        let defaults = resolve_remix_dimensions(&[], ExpandTask::TextToImage, true).unwrap();
1469        assert!(!defaults.contains(&RemixDimension::Style));
1470        assert!(
1471            resolve_remix_dimensions(&[RemixDimension::Style], ExpandTask::TextToImage, true)
1472                .is_err()
1473        );
1474    }
1475}