Skip to main content

perspt_sdk/model/
family.rs

1//! Model training lineage (PSP-9 system 1).
2//!
3//! Two distinctions carry weight and must not be collapsed:
4//!
5//! * **Provider ≠ family.** `groq`, `vertex`, and `ollama` are *hosts*.
6//!   Llama served by Groq and Llama served by Ollama are the same lineage and
7//!   will miss the same defects; two endpoints are not two opinions.
8//! * **Family is a prior, not a measurement.** Even a different family is not
9//!   guaranteed decorrelated — training corpora overlap. Family therefore only
10//!   seeds routing defaults; no numeric correlation is ever inferred from it.
11//!   Credited independence comes from matched labeled ledger statistics
12//!   (system 8), never from a vendor name.
13
14use serde::{Deserialize, Serialize};
15
16/// A model's training lineage, used only as a routing *prior*.
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ModelFamily {
20    OpenAiGpt,
21    AnthropicClaude,
22    GoogleGemini,
23    XaiGrok,
24    DeepSeek,
25    CohereCommand,
26    Llama,
27    Qwen,
28    Mistral,
29    Other(String),
30}
31
32impl ModelFamily {
33    /// Classify a provider-native model name into a lineage.
34    ///
35    /// This is a heuristic over public naming conventions and is applied to
36    /// the *model name*, never the provider key, so a Llama on any host
37    /// classifies the same. Unknown names map to [`ModelFamily::Other`]
38    /// rather than being guessed.
39    pub fn from_model_name(name: &str) -> Self {
40        let n = name.to_ascii_lowercase();
41        // Order matters where prefixes overlap (e.g. "gpt" inside other ids).
42        if n.contains("claude") {
43            ModelFamily::AnthropicClaude
44        } else if n.contains("gemini") || n.contains("gemma") {
45            ModelFamily::GoogleGemini
46        } else if n.contains("grok") {
47            ModelFamily::XaiGrok
48        } else if n.contains("deepseek") {
49            ModelFamily::DeepSeek
50        } else if n.contains("command") && !n.contains("gpt") {
51            ModelFamily::CohereCommand
52        } else if n.contains("llama") {
53            ModelFamily::Llama
54        } else if n.contains("qwen") {
55            ModelFamily::Qwen
56        } else if n.contains("mistral") || n.contains("mixtral") || n.contains("codestral") {
57            ModelFamily::Mistral
58        } else if n.contains("gpt") || n.starts_with("o1") || n.starts_with("o3") {
59            ModelFamily::OpenAiGpt
60        } else {
61            ModelFamily::Other(name.to_string())
62        }
63    }
64
65    /// Whether two lineages are distinct — a *routing precondition* (e.g.
66    /// `EnsemblePolicy::require_distinct_family`), never a risk statement.
67    /// Two [`ModelFamily::Other`] values compare by name because an unknown
68    /// lineage cannot be presumed distinct from itself.
69    pub fn is_distinct_from(&self, other: &ModelFamily) -> bool {
70        self != other
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn classifies_by_model_name_not_host() {
80        assert_eq!(
81            ModelFamily::from_model_name("llama-3.3-70b-versatile"),
82            ModelFamily::Llama
83        );
84        assert_eq!(
85            ModelFamily::from_model_name("claude-opus-5"),
86            ModelFamily::AnthropicClaude
87        );
88        assert_eq!(
89            ModelFamily::from_model_name("gemini-3.5-flash"),
90            ModelFamily::GoogleGemini
91        );
92        assert_eq!(
93            ModelFamily::from_model_name("gpt-5.5"),
94            ModelFamily::OpenAiGpt
95        );
96        assert_eq!(
97            ModelFamily::from_model_name("qwen2.5-coder"),
98            ModelFamily::Qwen
99        );
100    }
101
102    #[test]
103    fn unknown_names_stay_other_instead_of_being_guessed() {
104        assert_eq!(
105            ModelFamily::from_model_name("sonnetina-9000"),
106            ModelFamily::Other("sonnetina-9000".to_string())
107        );
108    }
109
110    #[test]
111    fn same_lineage_on_two_hosts_is_not_distinct() {
112        let groq = ModelFamily::from_model_name("llama-3.3-70b-versatile");
113        let ollama = ModelFamily::from_model_name("llama3.3:70b");
114        assert!(!groq.is_distinct_from(&ollama));
115    }
116}