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}