Skip to main content

skm_embed/
multicomp.rs

1//! Multi-component embeddings for skills.
2//!
3//! Skills are decomposed into semantic components (description, triggers, tags, examples)
4//! and embedded separately. Final scores use weighted combination.
5
6use serde::{Deserialize, Serialize};
7
8use skm_core::SkillName;
9
10use crate::embedding::Embedding;
11
12/// Component weights for multi-component scoring.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ComponentWeights {
15    /// Weight for description component.
16    pub description: f32,
17
18    /// Weight for triggers component.
19    pub triggers: f32,
20
21    /// Weight for tags component.
22    pub tags: f32,
23
24    /// Weight for examples component.
25    pub examples: f32,
26}
27
28impl Default for ComponentWeights {
29    fn default() -> Self {
30        Self {
31            description: 0.45,
32            triggers: 0.25,
33            tags: 0.15,
34            examples: 0.15,
35        }
36    }
37}
38
39impl ComponentWeights {
40    /// Create uniform weights (0.25 each).
41    pub fn uniform() -> Self {
42        Self {
43            description: 0.25,
44            triggers: 0.25,
45            tags: 0.25,
46            examples: 0.25,
47        }
48    }
49
50    /// Create description-only weights.
51    pub fn description_only() -> Self {
52        Self {
53            description: 1.0,
54            triggers: 0.0,
55            tags: 0.0,
56            examples: 0.0,
57        }
58    }
59
60    /// Normalize weights to sum to 1.0.
61    pub fn normalize(&mut self) {
62        let sum = self.description + self.triggers + self.tags + self.examples;
63        if sum > 0.0 {
64            self.description /= sum;
65            self.triggers /= sum;
66            self.tags /= sum;
67            self.examples /= sum;
68        }
69    }
70
71    /// Check if weights sum to approximately 1.0.
72    pub fn is_normalized(&self) -> bool {
73        let sum = self.description + self.triggers + self.tags + self.examples;
74        (sum - 1.0).abs() < 1e-5
75    }
76}
77
78/// Multi-component embedding for a single skill.
79/// Each component is embedded separately and scored with configurable weights.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SkillEmbeddings {
82    /// The skill name this embedding belongs to.
83    pub skill_name: SkillName,
84
85    /// Embedding of the main description text.
86    pub description: Embedding,
87
88    /// Embedding of trigger keywords concatenated.
89    pub triggers: Embedding,
90
91    /// Embedding of tags concatenated.
92    pub tags: Embedding,
93
94    /// Embedding of example inputs concatenated.
95    pub examples: Embedding,
96
97    /// Weights for combining component scores.
98    pub weights: ComponentWeights,
99}
100
101impl SkillEmbeddings {
102    /// Create new skill embeddings.
103    pub fn new(
104        skill_name: SkillName,
105        description: Embedding,
106        triggers: Embedding,
107        tags: Embedding,
108        examples: Embedding,
109        weights: ComponentWeights,
110    ) -> Self {
111        Self {
112            skill_name,
113            description,
114            triggers,
115            tags,
116            examples,
117            weights,
118        }
119    }
120
121    /// Compute weighted similarity score against a query embedding.
122    pub fn score(&self, query: &Embedding) -> f32 {
123        self.weights.description * self.description.cosine_similarity(query)
124            + self.weights.triggers * self.triggers.cosine_similarity(query)
125            + self.weights.tags * self.tags.cosine_similarity(query)
126            + self.weights.examples * self.examples.cosine_similarity(query)
127    }
128
129    /// Compute per-component scores against a query.
130    pub fn component_scores(&self, query: &Embedding) -> ComponentScores {
131        ComponentScores {
132            description: self.description.cosine_similarity(query),
133            triggers: self.triggers.cosine_similarity(query),
134            tags: self.tags.cosine_similarity(query),
135            examples: self.examples.cosine_similarity(query),
136        }
137    }
138
139    /// Update weights.
140    pub fn with_weights(mut self, weights: ComponentWeights) -> Self {
141        self.weights = weights;
142        self
143    }
144}
145
146/// Per-component similarity scores.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ComponentScores {
149    /// Score from description component.
150    pub description: f32,
151
152    /// Score from triggers component.
153    pub triggers: f32,
154
155    /// Score from tags component.
156    pub tags: f32,
157
158    /// Score from examples component.
159    pub examples: f32,
160}
161
162impl ComponentScores {
163    /// Compute weighted sum using given weights.
164    pub fn weighted_sum(&self, weights: &ComponentWeights) -> f32 {
165        weights.description * self.description
166            + weights.triggers * self.triggers
167            + weights.tags * self.tags
168            + weights.examples * self.examples
169    }
170
171    /// Get the maximum component score.
172    pub fn max(&self) -> f32 {
173        self.description
174            .max(self.triggers)
175            .max(self.tags)
176            .max(self.examples)
177    }
178
179    /// Get the component with highest score.
180    pub fn best_component(&self) -> &'static str {
181        let max = self.max();
182        if (self.description - max).abs() < 1e-6 {
183            "description"
184        } else if (self.triggers - max).abs() < 1e-6 {
185            "triggers"
186        } else if (self.tags - max).abs() < 1e-6 {
187            "tags"
188        } else {
189            "examples"
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn make_embedding(seed: u64) -> Embedding {
199        // Create a deterministic embedding based on seed
200        let mut vector = vec![0.0f32; 4];
201        for (i, v) in vector.iter_mut().enumerate() {
202            *v = ((seed + i as u64) % 100) as f32 / 100.0;
203        }
204        Embedding::new(vector, seed)
205    }
206
207    #[test]
208    fn test_component_weights_default() {
209        let weights = ComponentWeights::default();
210        assert!(weights.is_normalized());
211        assert!((weights.description - 0.45).abs() < 1e-5);
212    }
213
214    #[test]
215    fn test_component_weights_uniform() {
216        let weights = ComponentWeights::uniform();
217        assert!(weights.is_normalized());
218        assert!((weights.description - 0.25).abs() < 1e-5);
219    }
220
221    #[test]
222    fn test_component_weights_normalize() {
223        let mut weights = ComponentWeights {
224            description: 2.0,
225            triggers: 1.0,
226            tags: 1.0,
227            examples: 0.0,
228        };
229        weights.normalize();
230        assert!(weights.is_normalized());
231        assert!((weights.description - 0.5).abs() < 1e-5);
232        assert!((weights.triggers - 0.25).abs() < 1e-5);
233    }
234
235    #[test]
236    fn test_skill_embeddings_score() {
237        let skill_name = SkillName::new("test").unwrap();
238        let embeddings = SkillEmbeddings::new(
239            skill_name,
240            make_embedding(1),
241            make_embedding(2),
242            make_embedding(3),
243            make_embedding(4),
244            ComponentWeights::uniform(),
245        );
246
247        let query = make_embedding(1);
248        let score = embeddings.score(&query);
249
250        // Score should be between -1 and 1
251        assert!(score >= -1.0 && score <= 1.0);
252    }
253
254    #[test]
255    fn test_component_scores() {
256        let skill_name = SkillName::new("test").unwrap();
257        let embeddings = SkillEmbeddings::new(
258            skill_name,
259            make_embedding(1),
260            make_embedding(2),
261            make_embedding(3),
262            make_embedding(4),
263            ComponentWeights::uniform(),
264        );
265
266        let query = make_embedding(1);
267        let scores = embeddings.component_scores(&query);
268
269        // Description should have highest score (same seed)
270        assert!(scores.description > scores.triggers);
271    }
272
273    #[test]
274    fn test_component_scores_weighted_sum() {
275        let scores = ComponentScores {
276            description: 0.8,
277            triggers: 0.6,
278            tags: 0.4,
279            examples: 0.2,
280        };
281
282        let weights = ComponentWeights::uniform();
283        let sum = scores.weighted_sum(&weights);
284
285        // (0.8 + 0.6 + 0.4 + 0.2) * 0.25 = 0.5
286        assert!((sum - 0.5).abs() < 1e-5);
287    }
288
289    #[test]
290    fn test_component_scores_best() {
291        let scores = ComponentScores {
292            description: 0.3,
293            triggers: 0.9,
294            tags: 0.4,
295            examples: 0.2,
296        };
297
298        assert_eq!(scores.best_component(), "triggers");
299        assert!((scores.max() - 0.9).abs() < 1e-5);
300    }
301}