1use 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
18pub const DISCORD_MAX_VARIATIONS: usize = 5;
20
21pub const EXPANSION_CHUNK_SIZE: usize = 4;
25
26pub const EXPANSION_CHUNK_ATTEMPTS: usize = 3;
29
30pub const MAX_EXPANSION_VARIATIONS: usize = 10_000;
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub struct ExpansionAttempt {
40 pub start: usize,
42 pub total: usize,
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
48pub struct FamilyOverride {
49 pub word_limit: Option<u32>,
51 pub style_notes: Option<String>,
53}
54
55#[derive(Debug, Clone)]
57pub struct ExpandConfig {
58 pub model_family: String,
60 pub task: ExpandTask,
62 pub operation: PromptTransformOperation,
64 pub remix_dimensions: Vec<RemixDimension>,
66 pub variations: usize,
68 pub temperature: f64,
70 pub top_p: f64,
72 pub max_tokens: u32,
74 pub thinking: bool,
76 pub system_prompt: Option<String>,
79 pub batch_prompt: Option<String>,
82 pub family_overrides: HashMap<String, FamilyOverride>,
84 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
109pub 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
165pub 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#[derive(Debug, Clone)]
179pub struct ExpandResult {
180 pub original: String,
182 pub expanded: Vec<String>,
184}
185
186pub trait PromptExpander: Send + Sync {
188 fn expand(&self, prompt: &str, config: &ExpandConfig) -> Result<ExpandResult>;
190}
191
192pub 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
204pub 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 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
266pub 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#[derive(Debug, Serialize, Deserialize)]
296struct ChatMessage {
297 role: String,
298 content: String,
299}
300
301#[derive(Debug, Serialize)]
303struct ChatCompletionRequest {
304 model: String,
305 messages: Vec<ChatMessage>,
306 temperature: f64,
307 top_p: f64,
308 max_tokens: u32,
309 #[serde(skip_serializing_if = "std::ops::Not::not")]
314 enable_thinking: bool,
315}
316
317#[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
333pub struct ApiExpander {
335 endpoint: String,
336 model: String,
337}
338
339impl ApiExpander {
340 pub fn new(endpoint: &str, model: &str) -> Self {
341 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 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
448pub fn parse_variations_public(text: &str, expected: usize) -> Vec<String> {
450 parse_variations(text, expected)
451}
452
453pub fn clean_expanded_prompt_public(text: &str) -> String {
455 clean_expanded_prompt(text)
456}
457
458fn parse_variations(text: &str, expected: usize) -> Vec<String> {
461 let trimmed = text.trim();
462
463 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 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 let lines: Vec<String> = trimmed
486 .lines()
487 .map(|l| l.trim())
488 .filter(|l| !l.is_empty())
489 .map(|l| {
490 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 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 vec![clean_expanded_prompt(trimmed)]
519}
520
521fn clean_expanded_prompt(text: &str) -> String {
523 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 let cleaned = if let Some(end_idx) = trimmed.find("</think>") {
540 trimmed[end_idx + "</think>".len()..].trim()
541 } else {
542 trimmed
543 };
544
545 cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
547}
548
549#[derive(Debug, Clone, Deserialize, Serialize)]
551pub struct ExpandSettings {
552 #[serde(default)]
554 pub enabled: bool,
555 #[serde(default = "default_backend")]
557 pub backend: String,
558 #[serde(default = "default_expand_model")]
560 pub model: String,
561 #[serde(default = "default_api_model")]
563 pub api_model: String,
564 #[serde(default = "default_temperature")]
566 pub temperature: f64,
567 #[serde(default = "default_top_p")]
569 pub top_p: f64,
570 #[serde(default = "default_max_tokens")]
572 pub max_tokens: u32,
573 #[serde(default)]
575 pub thinking: bool,
576 #[serde(default)]
579 pub system_prompt: Option<String>,
580 #[serde(default)]
583 pub batch_prompt: Option<String>,
584 #[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 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 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: None,
690 }
691 }
692
693 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 pub fn active_model(&self) -> &str {
721 if self.is_local() {
722 &self.model
723 } else {
724 &self.api_model
725 }
726 }
727
728 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 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 #[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 #[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 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 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 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 let input = "[]";
884 let result = parse_variations(input, 3);
885 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 #[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 #[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 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 #[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 #[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 #[test]
1243 fn env_override_model_routes_to_local() {
1244 let settings = ExpandSettings::default();
1246 assert!(settings.is_local());
1247 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"); }
1259
1260 #[test]
1261 fn env_override_model_routes_to_api() {
1262 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"); }
1277
1278 #[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 let settings = ExpandSettings::default();
1329 let config = settings.to_expand_config("flux", 3);
1330 assert!(config.style.is_none());
1331 }
1332
1333 #[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 #[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 #[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}