Skip to main content

uni_store/runtime/
embed_caps.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Embedding-capability model: which vector heads an alias's task can produce, and
5//! which heads a schema's auto-embed columns require.
6//!
7//! Auto-embed always sources from a *text* column, so an alias is usable for a column
8//! only if its Uni-Xervo `ModelTask` can produce — from text — the head that column
9//! needs (dense / sparse / multi-vector). This module is the single source of truth for
10//! that mapping, shared by open-time schema validation in the `uni-db` crate and by
11//! write-time routing in [`crate::runtime::writer`].
12//!
13//! The task→heads mapping is a deliberate allow-list. `ModelTask` is
14//! `#[non_exhaustive]`, so any task that is not explicitly an embed-from-text task
15//! (image/audio/multimodal embedders, rerankers, generators, …) maps to *no* heads and
16//! is rejected, rather than silently slipping through when a future variant is added
17//! upstream. The mapping returns the task's *upper bound*; a specific hybrid model may
18//! expose fewer heads (its `available_heads()` is the runtime ground truth), which the
19//! writer enforces at inference time.
20
21use std::collections::BTreeMap;
22
23use uni_common::core::schema::{IndexDefinition, Schema};
24use uni_xervo::api::ModelTask;
25use uni_xervo::traits::HeadSet;
26
27/// Vector heads an alias's `task` can produce from a text source column.
28///
29/// Returns the task's *upper bound*: an `EmbedHybrid` alias maps to all three heads,
30/// even though a given hybrid model may expose fewer. Tasks that do not embed from text
31/// (image/audio/multimodal embedders, rerank, generate, raw, nlp, transcribe, ocr) and
32/// any future task variant map to an empty `HeadSet`.
33///
34/// # Examples
35///
36/// ```ignore
37/// assert_eq!(text_embedding_heads(ModelTask::Embed), HeadSet::DENSE);
38/// assert!(text_embedding_heads(ModelTask::Rerank).is_empty());
39/// ```
40pub fn text_embedding_heads(task: ModelTask) -> HeadSet {
41    match task {
42        ModelTask::Embed => HeadSet::DENSE,
43        ModelTask::EmbedSparse => HeadSet::SPARSE,
44        ModelTask::EmbedMultiVector => HeadSet::MULTI_VECTOR,
45        ModelTask::EmbedHybrid => HeadSet::ALL,
46        // Image/audio/multimodal embed from non-text inputs; rerank/generate/raw/nlp/
47        // transcribe/ocr are not text embeddings. None can auto-embed a text column.
48        _ => HeadSet::empty(),
49    }
50}
51
52/// Whether `property` on `label` is a multi-vector (`List<Vector>`) column.
53///
54/// The late-interaction (ColBERT) shape auto-embeds per-token via the multi-vector
55/// head; a plain `Vector` column uses the dense head (issue #104).
56pub(crate) fn is_multivector_property(schema: &Schema, label: &str, property: &str) -> bool {
57    schema
58        .properties
59        .get(label)
60        .and_then(|p| p.get(property))
61        .is_some_and(|m| {
62            matches!(&m.r#type, uni_common::DataType::List(inner)
63                if matches!(**inner, uni_common::DataType::Vector { .. }))
64        })
65}
66
67/// Embedding heads a single alias must produce, with the columns that require them.
68#[derive(Debug, Clone)]
69pub struct RequiredHeads {
70    /// Union of heads needed by every auto-embed column bound to the alias.
71    pub heads: HeadSet,
72    /// `(column, head)` contributors, in schema order, for diagnostics.
73    pub columns: Vec<(String, HeadSet)>,
74}
75
76/// Per-alias embedding-head requirements implied by a schema's auto-embed indexes.
77///
78/// Walks both `Vector` (classified dense vs multi-vector by column type) and `Sparse`
79/// index definitions that carry an embedding config, unioning the heads each alias must
80/// produce. The result drives the open-time capability check
81/// `required ⊆ text_embedding_heads(task)` and names offending columns on failure.
82///
83/// # Examples
84///
85/// ```ignore
86/// let required = required_embed_heads(&schema);
87/// for (alias, req) in &required {
88///     assert!(text_embedding_heads(task_of(alias)).contains(req.heads));
89/// }
90/// ```
91pub fn required_embed_heads(schema: &Schema) -> BTreeMap<String, RequiredHeads> {
92    let mut out: BTreeMap<String, RequiredHeads> = BTreeMap::new();
93    for idx in &schema.indexes {
94        let (alias, column, head) = match idx {
95            IndexDefinition::Vector(cfg) => {
96                let Some(emb) = cfg.embedding_config.as_ref() else {
97                    continue;
98                };
99                let head = if is_multivector_property(schema, &cfg.label, &cfg.property) {
100                    HeadSet::MULTI_VECTOR
101                } else {
102                    HeadSet::DENSE
103                };
104                (emb.alias.clone(), cfg.property.clone(), head)
105            }
106            IndexDefinition::Sparse(cfg) => {
107                let Some(emb) = cfg.embedding_config.as_ref() else {
108                    continue;
109                };
110                (emb.alias.clone(), cfg.property.clone(), HeadSet::SPARSE)
111            }
112            // FullText / Scalar / Inverted / JsonFullText carry no embedding config,
113            // and `IndexDefinition` is `#[non_exhaustive]`: only the embed-bearing
114            // variants above contribute required heads.
115            _ => continue,
116        };
117        let entry = out.entry(alias).or_insert_with(|| RequiredHeads {
118            heads: HeadSet::empty(),
119            columns: Vec::new(),
120        });
121        entry.heads |= head;
122        entry.columns.push((column, head));
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn text_tasks_map_to_their_head() {
133        assert_eq!(text_embedding_heads(ModelTask::Embed), HeadSet::DENSE);
134        assert_eq!(
135            text_embedding_heads(ModelTask::EmbedSparse),
136            HeadSet::SPARSE
137        );
138        assert_eq!(
139            text_embedding_heads(ModelTask::EmbedMultiVector),
140            HeadSet::MULTI_VECTOR
141        );
142        assert_eq!(text_embedding_heads(ModelTask::EmbedHybrid), HeadSet::ALL);
143    }
144
145    #[test]
146    fn hybrid_covers_every_single_head() {
147        let hybrid = text_embedding_heads(ModelTask::EmbedHybrid);
148        for head in [HeadSet::DENSE, HeadSet::SPARSE, HeadSet::MULTI_VECTOR] {
149            assert!(hybrid.contains(head), "hybrid must cover {head:?}");
150        }
151    }
152
153    #[test]
154    fn non_text_and_non_embed_tasks_map_to_no_heads() {
155        // Image/audio/multimodal embed from non-text inputs; the rest are not
156        // embeddings. None is a valid text auto-embed target (issue #129/#130 §4.1).
157        for task in [
158            ModelTask::EmbedImage,
159            ModelTask::EmbedAudio,
160            ModelTask::EmbedMultimodal,
161            ModelTask::Rerank,
162            ModelTask::Generate,
163            ModelTask::Raw,
164            ModelTask::Nlp,
165            ModelTask::DocumentExtract,
166            ModelTask::Transcribe,
167            ModelTask::Ocr,
168        ] {
169            assert!(
170                text_embedding_heads(task).is_empty(),
171                "task {task:?} must produce no text-embedding heads"
172            );
173        }
174    }
175
176    #[test]
177    fn single_task_alias_rejects_a_foreign_head() {
178        // The open-time invariant `required ⊆ text_embedding_heads(task)`.
179        let dense_only = text_embedding_heads(ModelTask::Embed);
180        assert!(dense_only.contains(HeadSet::DENSE));
181        assert!(!dense_only.contains(HeadSet::SPARSE));
182        assert!(!dense_only.contains(HeadSet::MULTI_VECTOR));
183        // A dense+sparse mix is only coverable by a hybrid alias.
184        let mixed = HeadSet::DENSE | HeadSet::SPARSE;
185        assert!(!dense_only.contains(mixed));
186        assert!(text_embedding_heads(ModelTask::EmbedHybrid).contains(mixed));
187    }
188}