Skip to main content

aurum_core/
profile.rs

1//! Intent-oriented quality profiles (JOE-1723).
2//!
3//! Profiles resolve to verified catalogue model IDs using a **versioned evidence
4//! mapping**. They never select experimental models (e.g. `large-v3-q5_0`).
5//! Explicit `--model` always wins over a profile.
6//!
7//! The default product model remains `base` until an evidence review changes it;
8//! profiles are opt-in intents, not silent default rewrites.
9
10use crate::error::{Result, UserError};
11use crate::model::{lookup_model, model_support_tier, ModelSupportTier};
12use serde::{Deserialize, Serialize};
13
14/// Evidence pack version tied to published eval methodology (JOE-2216).
15///
16/// Tracks the STT quality observatory evidence pack. Bump when mappings or
17/// the reviewed baseline change; requires a changelog entry and budget review.
18/// See `evals/observatory/` and [`crate::eval::STT_OBSERVATORY_EVIDENCE_VERSION`].
19pub const PROFILE_EVIDENCE_VERSION: &str = "0.0.22-observatory-v1";
20
21/// User-facing quality intent.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum QualityProfile {
25    /// Prefer low download size and load time (trial / constrained machines).
26    Speed,
27    /// Default product intent (maps to the historical `base` default family).
28    Balance,
29    /// Prefer higher accuracy among supported catalogue models (not experimental).
30    Quality,
31}
32
33impl QualityProfile {
34    pub fn parse(s: &str) -> Result<Self> {
35        match s.trim().to_ascii_lowercase().as_str() {
36            "speed" | "fast" => Ok(Self::Speed),
37            "balance" | "balanced" | "default" => Ok(Self::Balance),
38            "quality" | "accurate" | "high" => Ok(Self::Quality),
39            other => Err(UserError::Other {
40                message: format!(
41                    "unknown quality profile '{other}'\n  \
42                     Hint: use speed | balance | quality"
43                ),
44            }
45            .into()),
46        }
47    }
48
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Speed => "speed",
52            Self::Balance => "balance",
53            Self::Quality => "quality",
54        }
55    }
56}
57
58/// Deterministic profile → model resolution with full transparency.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct ProfileResolution {
61    pub profile: String,
62    pub model: String,
63    pub evidence_version: String,
64    pub language: String,
65    pub reasons: Vec<String>,
66    pub alternatives: Vec<String>,
67    pub approx_bytes: u64,
68    pub support_tier: String,
69}
70
71/// Resolve a profile for the given language preference (`auto`, `en`, …).
72///
73/// Rules:
74/// - Never select [`ModelSupportTier::Experimental`].
75/// - Prefer English-only quantised models when language is clearly English.
76/// - Network is never required; resolution is pure catalogue lookup.
77pub fn resolve_profile(profile: QualityProfile, language: &str) -> Result<ProfileResolution> {
78    let lang = language.trim().to_ascii_lowercase();
79    let english = lang == "en" || lang == "eng" || lang.starts_with("en-");
80
81    let (model, reasons): (&str, Vec<String>) = match profile {
82        QualityProfile::Speed => {
83            if english {
84                (
85                    "tiny.en-q5_1",
86                    vec![
87                        "speed profile prefers smallest supported download".into(),
88                        "language is English → english-only quantised tiny".into(),
89                    ],
90                )
91            } else {
92                (
93                    "tiny-q5_1",
94                    vec![
95                        "speed profile prefers smallest supported download".into(),
96                        "language is not fixed English → multilingual tiny-q5_1".into(),
97                    ],
98                )
99            }
100        }
101        QualityProfile::Balance => {
102            if english {
103                (
104                    "base.en",
105                    vec![
106                        "balance profile tracks the product default family".into(),
107                        "language is English → base.en".into(),
108                        format!(
109                            "evidence_version={PROFILE_EVIDENCE_VERSION} does not change global default"
110                        ),
111                    ],
112                )
113            } else {
114                (
115                    "base",
116                    vec![
117                        "balance profile tracks the product default model `base`".into(),
118                        format!("evidence_version={PROFILE_EVIDENCE_VERSION} (STT observatory)"),
119                    ],
120                )
121            }
122        }
123        QualityProfile::Quality => {
124            if english {
125                (
126                    "small.en",
127                    vec![
128                        "quality profile selects next supported size above base".into(),
129                        "language is English → small.en".into(),
130                        "experimental large-v3-q5_0 is never selected by profiles".into(),
131                    ],
132                )
133            } else {
134                (
135                    "small",
136                    vec![
137                        "quality profile selects next supported size above base".into(),
138                        "experimental large-v3-q5_0 is never selected by profiles".into(),
139                    ],
140                )
141            }
142        }
143    };
144
145    let info = lookup_model(model)?;
146    match model_support_tier(info.name) {
147        ModelSupportTier::Supported => {}
148        ModelSupportTier::Experimental => {
149            return Err(UserError::Other {
150                message: format!(
151                    "internal error: profile resolved experimental model '{}'",
152                    info.name
153                ),
154            }
155            .into());
156        }
157    }
158
159    let candidates: &[&str] = match profile {
160        QualityProfile::Speed => &["tiny", "tiny-q8_0", "base-q5_1"],
161        QualityProfile::Balance => &["base-q5_1", "base-q8_0", "small-q5_1"],
162        QualityProfile::Quality => &["small-q5_1", "medium", "large-v3-turbo"],
163    };
164    let alternatives: Vec<String> = candidates
165        .iter()
166        .copied()
167        .filter(|m| lookup_model(m).is_ok() && model_support_tier(m) == ModelSupportTier::Supported)
168        .filter(|m| *m != info.name)
169        .map(str::to_string)
170        .collect();
171
172    Ok(ProfileResolution {
173        profile: profile.as_str().into(),
174        model: info.name.into(),
175        evidence_version: PROFILE_EVIDENCE_VERSION.into(),
176        language: language.into(),
177        reasons,
178        alternatives,
179        approx_bytes: info.approx_bytes,
180        support_tier: "supported".into(),
181    })
182}
183
184/// Format a human-readable recommendation block.
185pub fn format_recommendation(res: &ProfileResolution) -> String {
186    let mut out = String::new();
187    out.push_str(&format!(
188        "profile={} → model={}  (tier={}, ~{} MB)\n",
189        res.profile,
190        res.model,
191        res.support_tier,
192        res.approx_bytes / 1_000_000
193    ));
194    out.push_str(&format!("evidence_version={}\n", res.evidence_version));
195    out.push_str(&format!("language={}\n", res.language));
196    out.push_str("reasons:\n");
197    for r in &res.reasons {
198        out.push_str(&format!("  - {r}\n"));
199    }
200    if !res.alternatives.is_empty() {
201        out.push_str(&format!("alternatives: {}\n", res.alternatives.join(", ")));
202    }
203    out.push_str("override: pass --model <id> to force a catalogue model\n");
204    out
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn speed_never_experimental() {
213        for lang in ["auto", "en", "fr"] {
214            let r = resolve_profile(QualityProfile::Speed, lang).unwrap();
215            assert_eq!(model_support_tier(&r.model), ModelSupportTier::Supported);
216            assert!(!r.model.contains("large-v3-q5_0"));
217        }
218    }
219
220    #[test]
221    fn balance_defaults_to_base_family() {
222        let r = resolve_profile(QualityProfile::Balance, "auto").unwrap();
223        assert_eq!(r.model, "base");
224        let en = resolve_profile(QualityProfile::Balance, "en").unwrap();
225        assert_eq!(en.model, "base.en");
226    }
227
228    #[test]
229    fn quality_uses_small_not_experimental_large() {
230        let r = resolve_profile(QualityProfile::Quality, "auto").unwrap();
231        assert_eq!(r.model, "small");
232        assert_ne!(r.model, "large-v3-q5_0");
233    }
234
235    #[test]
236    fn parse_aliases() {
237        assert_eq!(
238            QualityProfile::parse("fast").unwrap(),
239            QualityProfile::Speed
240        );
241        assert_eq!(
242            QualityProfile::parse("balanced").unwrap(),
243            QualityProfile::Balance
244        );
245    }
246}