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.
15/// Bump when mappings change; requires changelog entry.
16pub const PROFILE_EVIDENCE_VERSION: &str = "0.0.4-provisional-smoke";
17
18/// User-facing quality intent.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum QualityProfile {
22    /// Prefer low download size and load time (trial / constrained machines).
23    Speed,
24    /// Default product intent (maps to the historical `base` default family).
25    Balance,
26    /// Prefer higher accuracy among supported catalogue models (not experimental).
27    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/// Deterministic profile → model resolution with full transparency.
56#[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
68/// Resolve a profile for the given language preference (`auto`, `en`, …).
69///
70/// Rules:
71/// - Never select [`ModelSupportTier::Experimental`].
72/// - Prefer English-only quantised models when language is clearly English.
73/// - Network is never required; resolution is pure catalogue lookup.
74pub 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
181/// Format a human-readable recommendation block.
182pub 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}