1use crate::error::{Result, UserError};
11use crate::model::{lookup_model, model_support_tier, ModelSupportTier};
12use serde::{Deserialize, Serialize};
13
14pub const PROFILE_EVIDENCE_VERSION: &str = "0.0.4-provisional-smoke";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum QualityProfile {
22 Speed,
24 Balance,
26 Quality,
28}
29
30impl QualityProfile {
31 pub fn parse(s: &str) -> Result<Self> {
32 match s.trim().to_ascii_lowercase().as_str() {
33 "speed" | "fast" => Ok(Self::Speed),
34 "balance" | "balanced" | "default" => Ok(Self::Balance),
35 "quality" | "accurate" | "high" => Ok(Self::Quality),
36 other => Err(UserError::Other {
37 message: format!(
38 "unknown quality profile '{other}'\n \
39 Hint: use speed | balance | quality"
40 ),
41 }
42 .into()),
43 }
44 }
45
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::Speed => "speed",
49 Self::Balance => "balance",
50 Self::Quality => "quality",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct ProfileResolution {
58 pub profile: String,
59 pub model: String,
60 pub evidence_version: String,
61 pub language: String,
62 pub reasons: Vec<String>,
63 pub alternatives: Vec<String>,
64 pub approx_bytes: u64,
65 pub support_tier: String,
66}
67
68pub fn resolve_profile(profile: QualityProfile, language: &str) -> Result<ProfileResolution> {
75 let lang = language.trim().to_ascii_lowercase();
76 let english = lang == "en" || lang == "eng" || lang.starts_with("en-");
77
78 let (model, reasons): (&str, Vec<String>) = match profile {
79 QualityProfile::Speed => {
80 if english {
81 (
82 "tiny.en-q5_1",
83 vec![
84 "speed profile prefers smallest supported download".into(),
85 "language is English → english-only quantised tiny".into(),
86 ],
87 )
88 } else {
89 (
90 "tiny-q5_1",
91 vec![
92 "speed profile prefers smallest supported download".into(),
93 "language is not fixed English → multilingual tiny-q5_1".into(),
94 ],
95 )
96 }
97 }
98 QualityProfile::Balance => {
99 if english {
100 (
101 "base.en",
102 vec![
103 "balance profile tracks the product default family".into(),
104 "language is English → base.en".into(),
105 format!(
106 "evidence_version={PROFILE_EVIDENCE_VERSION} does not change global default"
107 ),
108 ],
109 )
110 } else {
111 (
112 "base",
113 vec![
114 "balance profile tracks the product default model `base`".into(),
115 format!("evidence_version={PROFILE_EVIDENCE_VERSION} (provisional smoke)"),
116 ],
117 )
118 }
119 }
120 QualityProfile::Quality => {
121 if english {
122 (
123 "small.en",
124 vec![
125 "quality profile selects next supported size above base".into(),
126 "language is English → small.en".into(),
127 "experimental large-v3-q5_0 is never selected by profiles".into(),
128 ],
129 )
130 } else {
131 (
132 "small",
133 vec![
134 "quality profile selects next supported size above base".into(),
135 "experimental large-v3-q5_0 is never selected by profiles".into(),
136 ],
137 )
138 }
139 }
140 };
141
142 let info = lookup_model(model)?;
143 match model_support_tier(info.name) {
144 ModelSupportTier::Supported => {}
145 ModelSupportTier::Experimental => {
146 return Err(UserError::Other {
147 message: format!(
148 "internal error: profile resolved experimental model '{}'",
149 info.name
150 ),
151 }
152 .into());
153 }
154 }
155
156 let candidates: &[&str] = match profile {
157 QualityProfile::Speed => &["tiny", "tiny-q8_0", "base-q5_1"],
158 QualityProfile::Balance => &["base-q5_1", "base-q8_0", "small-q5_1"],
159 QualityProfile::Quality => &["small-q5_1", "medium", "large-v3-turbo"],
160 };
161 let alternatives: Vec<String> = candidates
162 .iter()
163 .copied()
164 .filter(|m| lookup_model(m).is_ok() && model_support_tier(m) == ModelSupportTier::Supported)
165 .filter(|m| *m != info.name)
166 .map(str::to_string)
167 .collect();
168
169 Ok(ProfileResolution {
170 profile: profile.as_str().into(),
171 model: info.name.into(),
172 evidence_version: PROFILE_EVIDENCE_VERSION.into(),
173 language: language.into(),
174 reasons,
175 alternatives,
176 approx_bytes: info.approx_bytes,
177 support_tier: "supported".into(),
178 })
179}
180
181pub fn format_recommendation(res: &ProfileResolution) -> String {
183 let mut out = String::new();
184 out.push_str(&format!(
185 "profile={} → model={} (tier={}, ~{} MB)\n",
186 res.profile,
187 res.model,
188 res.support_tier,
189 res.approx_bytes / 1_000_000
190 ));
191 out.push_str(&format!("evidence_version={}\n", res.evidence_version));
192 out.push_str(&format!("language={}\n", res.language));
193 out.push_str("reasons:\n");
194 for r in &res.reasons {
195 out.push_str(&format!(" - {r}\n"));
196 }
197 if !res.alternatives.is_empty() {
198 out.push_str(&format!("alternatives: {}\n", res.alternatives.join(", ")));
199 }
200 out.push_str("override: pass --model <id> to force a catalogue model\n");
201 out
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn speed_never_experimental() {
210 for lang in ["auto", "en", "fr"] {
211 let r = resolve_profile(QualityProfile::Speed, lang).unwrap();
212 assert_eq!(model_support_tier(&r.model), ModelSupportTier::Supported);
213 assert!(!r.model.contains("large-v3-q5_0"));
214 }
215 }
216
217 #[test]
218 fn balance_defaults_to_base_family() {
219 let r = resolve_profile(QualityProfile::Balance, "auto").unwrap();
220 assert_eq!(r.model, "base");
221 let en = resolve_profile(QualityProfile::Balance, "en").unwrap();
222 assert_eq!(en.model, "base.en");
223 }
224
225 #[test]
226 fn quality_uses_small_not_experimental_large() {
227 let r = resolve_profile(QualityProfile::Quality, "auto").unwrap();
228 assert_eq!(r.model, "small");
229 assert_ne!(r.model, "large-v3-q5_0");
230 }
231
232 #[test]
233 fn parse_aliases() {
234 assert_eq!(
235 QualityProfile::parse("fast").unwrap(),
236 QualityProfile::Speed
237 );
238 assert_eq!(
239 QualityProfile::parse("balanced").unwrap(),
240 QualityProfile::Balance
241 );
242 }
243}