Skip to main content

haem_frames/embedding/
checks.rs

1use std::collections::BTreeSet;
2use std::path::Path;
3
4use super::{
5    Embed, Error, FailedCode, ItemCode, Kind, Limits, MINIMUM_FRAME_BYTES, Model, Outcome,
6    PROTOCOL, PROTOCOL_WINDOWS, Ready, Start, Vectors, Window, Windows, decode_vector,
7};
8
9fn limits(message: impl Into<String>) -> Error {
10    Error::new(FailedCode::EmbedLimits, message)
11}
12
13pub fn check_start(start: &Start) -> Result<(), Error> {
14    // EMB-E2: windows are asked for by protocol 2 and only by it.
15    let windows = match (start.protocol, start.windows) {
16        (PROTOCOL, None) => None,
17        (PROTOCOL_WINDOWS, Some(windows)) => Some(windows),
18        _ => {
19            return Err(Error::new(
20                FailedCode::EmbedProtocol,
21                "unsupported embedding protocol",
22            ));
23        }
24    };
25    let value = &start.limits;
26    if [
27        value.memory_bytes,
28        value.threads,
29        value.batch_items,
30        value.input_bytes,
31        value.tokens,
32    ]
33    .contains(&0)
34        || value.frame_bytes < MINIMUM_FRAME_BYTES
35    {
36        return Err(limits(
37            "all limits must be positive and frame_bytes must be at least 8192",
38        ));
39    }
40    if !Path::new(&start.model_dir).is_absolute() {
41        return Err(limits("model_dir must be absolute"));
42    }
43    if windows.is_some_and(|windows| {
44        windows.max_windows < 2 || windows.overlap_tokens >= start.limits.tokens
45    }) {
46        return Err(limits(
47            "max_windows must be at least 2 and overlap_tokens below tokens",
48        ));
49    }
50    Ok(())
51}
52
53/// Ready names protocol 1 or, for a Start that asked for windows, 2; the
54/// service checks it names the protocol its Start did.
55pub fn check_ready(ready: &Ready) -> Result<(), Error> {
56    let model = &ready.model;
57    if ![PROTOCOL, PROTOCOL_WINDOWS].contains(&ready.protocol)
58        || model.name.is_empty()
59        || model.manifest_sha256.len() != 64
60        || !model
61            .manifest_sha256
62            .bytes()
63            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
64        || model.dimensions == 0
65        || model.max_tokens == 0
66        || model.special_tokens >= model.max_tokens
67    {
68        return Err(Error::new(
69            FailedCode::EmbedProtocol,
70            "Ready has invalid protocol or model identity",
71        ));
72    }
73    Ok(())
74}
75
76/// Upper bound for tag plus compact JSON, not the usual message size.
77/// Includes maximally escaped ids and the widest whole-number token counts.
78pub fn vectors_message_size(batch_items: u64, dimensions: u64) -> Result<u64, Error> {
79    if batch_items == 0 || dimensions == 0 {
80        return Err(limits("batch_items and dimensions must be positive"));
81    }
82    // Each legal single-byte control character can take six JSON bytes.
83    let id = 512_u64 * 6;
84    let fixed = u64::try_from(
85        b"{\"outcome\":\"vector\",\"id\":\"\",\"vector\":\"\",\"tokens_read\":,\"tokens_total\":}"
86            .len(),
87    )
88    .map_err(|_| limits("message size overflows"))?;
89    let item = dimensions
90        .checked_mul(8)
91        .and_then(|vector| vector.checked_add(id + fixed + 40))
92        .ok_or_else(|| limits("message size overflows"))?;
93    let items = batch_items
94        .checked_mul(item)
95        .and_then(|value| value.checked_add(batch_items - 1))
96        .ok_or_else(|| limits("message size overflows"))?;
97    items
98        .checked_add(13)
99        .ok_or_else(|| limits("message size overflows"))
100}
101
102/// EMB-E2: [`vectors_message_size`] when each item may answer up to
103/// `max_windows` windows, each its bounds, its vector and its token count.
104///
105/// # Errors
106/// `embed_limits` when a count is zero or the size overflows.
107pub fn windows_message_size(
108    batch_items: u64,
109    dimensions: u64,
110    max_windows: u64,
111) -> Result<u64, Error> {
112    let overflow = || limits("message size overflows");
113    let fixed = |bytes: &[u8]| u64::try_from(bytes.len()).map_err(|_| overflow());
114    let item = fixed(b"{\"outcome\":\"windows\",\"id\":\"\",\"windows\":[],\"tokens_total\":}")?;
115    let window = fixed(b"{\"start\":,\"end\":,\"vector\":\"\",\"tokens_read\":},")?;
116    // Each legal single-byte control character can take six JSON bytes.
117    let id = 512_u64 * 6;
118    let windows = dimensions
119        .checked_mul(8)
120        .and_then(|vector| vector.checked_add(window + 60))
121        .and_then(|one| one.checked_mul(max_windows))
122        .ok_or_else(overflow)?;
123    let one = windows.checked_add(item + id + 20).ok_or_else(overflow)?;
124    let ordinary = vectors_message_size(batch_items, dimensions)?;
125    batch_items
126        .checked_mul(one)
127        .and_then(|items| items.checked_add(batch_items + 13))
128        .map(|size| size.max(ordinary))
129        .ok_or_else(overflow)
130}
131
132pub fn check_model_limits(limits_value: &Limits, model: &Model) -> Result<(), Error> {
133    check_model_limits_windowed(limits_value, model, None)
134}
135
136/// [`check_model_limits`], and with `windows` (EMB-E2) a frame that carries
137/// the largest windowed answer and an overlap below the model's content
138/// tokens, so every window moves forward through its text.
139///
140/// # Errors
141/// `embed_limits`, naming the limit.
142pub fn check_model_limits_windowed(
143    limits_value: &Limits,
144    model: &Model,
145    windows: Option<Windows>,
146) -> Result<(), Error> {
147    let minimum_tokens = model
148        .special_tokens
149        .checked_add(model.prefix_tokens)
150        .and_then(|reserved| reserved.checked_add(1));
151    if limits_value.tokens > model.max_tokens
152        || minimum_tokens.is_none_or(|minimum| limits_value.tokens < minimum)
153    {
154        return Err(limits(format!(
155            "tokens {} must be at least special_tokens {} + prefix_tokens {} + 1 and not exceed max_tokens {}",
156            limits_value.tokens, model.special_tokens, model.prefix_tokens, model.max_tokens
157        )));
158    }
159    let largest = match windows {
160        None => vectors_message_size(limits_value.batch_items, model.dimensions)?,
161        Some(windows) => windows_message_size(
162            limits_value.batch_items,
163            model.dimensions,
164            windows.max_windows,
165        )?,
166    };
167    if u64::from(limits_value.frame_bytes) < largest {
168        return Err(limits(
169            "frame_bytes cannot carry the largest legal Vectors batch",
170        ));
171    }
172    let content = model
173        .max_tokens
174        .saturating_sub(model.special_tokens)
175        .saturating_sub(model.prefix_tokens);
176    if windows.is_some_and(|windows| windows.overlap_tokens >= content) {
177        return Err(limits(format!(
178            "overlap_tokens must be below the model's {content} content tokens"
179        )));
180    }
181    Ok(())
182}
183
184fn id_valid(id: &str) -> bool {
185    !id.is_empty() && id.len() <= 512 && !id.contains('\0')
186}
187
188pub fn check_embed(embed: &Embed, limits_value: &Limits) -> Result<(), Error> {
189    if embed.items.is_empty() {
190        return Err(Error::new(
191            FailedCode::EmbedProtocol,
192            "Embed must contain at least one item",
193        ));
194    }
195    let count = u64::try_from(embed.items.len()).map_err(|_| {
196        Error::new(
197            FailedCode::EmbedBatchTooLarge,
198            "batch count exceeds its limit",
199        )
200    })?;
201    if count > limits_value.batch_items {
202        return Err(Error::new(
203            FailedCode::EmbedBatchTooLarge,
204            "Embed exceeds batch_items",
205        ));
206    }
207    let mut seen = BTreeSet::new();
208    for item in &embed.items {
209        if !id_valid(&item.id) || !seen.insert(item.id.as_str()) {
210            return Err(Error::new(
211                FailedCode::EmbedProtocol,
212                "Embed has a bad or repeated id",
213            ));
214        }
215    }
216    Ok(())
217}
218
219pub fn input_refusal(text: &str, input_bytes: u64) -> Option<ItemCode> {
220    if text.chars().all(char::is_whitespace) {
221        return Some(ItemCode::EmbedInputEmpty);
222    }
223    match u64::try_from(text.len()) {
224        Ok(bytes) if bytes <= input_bytes => None,
225        Ok(_) | Err(_) => Some(ItemCode::EmbedInputTooLarge),
226    }
227}
228
229fn invalid_output() -> Error {
230    Error::new(
231        FailedCode::EmbedOutputInvalid,
232        "Vectors content does not satisfy the request and model",
233    )
234}
235
236/// The content tokens the model reads after its special and prefix tokens.
237const fn content_tokens(ready: &Ready) -> u64 {
238    ready
239        .model
240        .max_tokens
241        .saturating_sub(ready.model.special_tokens)
242        .saturating_sub(ready.model.prefix_tokens)
243}
244
245/// A refusal is valid when it names the byte classifier's own code and carries no token counts,
246/// or, for a text the classifier passed, says `text_longer_than_model` with the text's content
247/// tokens beyond what the model reads after its special and prefix tokens, and the model's limit
248/// as Ready declared it. EMB-E2: `windows_over_cap` is the same, and only when windows were
249/// asked for.
250fn refusal_fits(
251    code: ItemCode,
252    expected: Option<ItemCode>,
253    (tokens_total, tokens_limit): (Option<u64>, Option<u64>),
254    ready: &Ready,
255    windowed: bool,
256) -> bool {
257    let longer = |total: u64, limit: u64| {
258        expected.is_none() && limit == ready.model.max_tokens && total > content_tokens(ready)
259    };
260    match (code, tokens_total, tokens_limit) {
261        (ItemCode::TextLongerThanModel, Some(total), Some(limit)) => longer(total, limit),
262        (ItemCode::WindowsOverCap, Some(total), Some(limit)) => windowed && longer(total, limit),
263        (ItemCode::TextLongerThanModel | ItemCode::WindowsOverCap, _, _)
264        | (_, Some(_), _)
265        | (_, _, Some(_)) => false,
266        (_, None, None) => expected == Some(code),
267    }
268}
269
270/// One encoded vector of the model's dimensions, of length one when the
271/// model says its vectors are normalised.
272fn check_vector(vector: &str, ready: &Ready) -> Result<(), Error> {
273    let decoded = decode_vector(vector)?;
274    let dimensions = u64::try_from(decoded.len()).map_err(|_| invalid_output())?;
275    if dimensions != ready.model.dimensions {
276        return Err(invalid_output());
277    }
278    if ready.model.normalised {
279        let squared = decoded
280            .iter()
281            .map(|value| {
282                let member = f64::from(*value);
283                member * member
284            })
285            .sum::<f64>();
286        if (squared.sqrt() - 1.0).abs() > 0.0001 {
287            return Err(invalid_output());
288        }
289    }
290    Ok(())
291}
292
293/// EMB-E2: a text's windows cover it whole. The first starts at 0 and the
294/// last ends at its length; each starts after the one before it starts, no
295/// later than it ends, and ends after it; every bound is on a char boundary;
296/// there are 2 to `max_windows` of them, for a text longer than the model
297/// reads; and each window was read whole.
298fn check_windows(
299    windows: &[Window],
300    tokens_total: u64,
301    text: &str,
302    ready: &Ready,
303    limits_value: &Limits,
304    asked: Windows,
305) -> Result<(), Error> {
306    let count = u64::try_from(windows.len()).map_err(|_| invalid_output())?;
307    let length = u64::try_from(text.len()).map_err(|_| invalid_output())?;
308    if count < 2 || count > asked.max_windows || tokens_total <= content_tokens(ready) {
309        return Err(invalid_output());
310    }
311    let boundary = |at: u64| usize::try_from(at).is_ok_and(|at| text.is_char_boundary(at));
312    let mut previous: Option<&Window> = None;
313    for window in windows {
314        let follows = previous.map_or(window.start == 0, |before| {
315            (before.start..=before.end).contains(&window.start)
316                && window.start != before.start
317                && window.end > before.end
318        });
319        if !follows
320            || window.end <= window.start
321            || window.end > length
322            || !boundary(window.start)
323            || !boundary(window.end)
324            || window.tokens_read == 0
325            || window.tokens_read > limits_value.tokens
326        {
327            return Err(invalid_output());
328        }
329        check_vector(&window.vector, ready)?;
330        previous = Some(window);
331    }
332    if previous.map(|last| last.end) != Some(length) {
333        return Err(invalid_output());
334    }
335    Ok(())
336}
337
338pub fn check_vectors(
339    vectors: &Vectors,
340    embed: &Embed,
341    ready: &Ready,
342    limits_value: &Limits,
343) -> Result<(), Error> {
344    check_vectors_windowed(vectors, embed, ready, limits_value, None)
345}
346
347/// [`check_vectors`] for a worker started with `windows` (EMB-E2): an item of
348/// a document Embed may also answer its windows, or `windows_over_cap`; a
349/// question is never windowed.
350///
351/// # Errors
352/// `embed_protocol` for a count or order that does not match the Embed;
353/// `embed_output_invalid` for any outcome the request and model do not allow.
354pub fn check_vectors_windowed(
355    vectors: &Vectors,
356    embed: &Embed,
357    ready: &Ready,
358    limits_value: &Limits,
359    windows: Option<Windows>,
360) -> Result<(), Error> {
361    if vectors.items.len() != embed.items.len() {
362        return Err(Error::new(
363            FailedCode::EmbedProtocol,
364            "Vectors count does not match Embed",
365        ));
366    }
367    let windows = windows.filter(|_| embed.kind == Kind::Document);
368    for (outcome, input) in vectors.items.iter().zip(&embed.items) {
369        if outcome.id() != input.id {
370            return Err(Error::new(
371                FailedCode::EmbedProtocol,
372                "Vectors ids do not match Embed order",
373            ));
374        }
375        let expected = input_refusal(&input.text, limits_value.input_bytes);
376        match outcome {
377            Outcome::Refused {
378                code,
379                tokens_total,
380                tokens_limit,
381                ..
382            } => {
383                let counts = (*tokens_total, *tokens_limit);
384                if !refusal_fits(*code, expected, counts, ready, windows.is_some()) {
385                    return Err(invalid_output());
386                }
387            }
388            Outcome::Vector {
389                vector,
390                tokens_read,
391                tokens_total,
392                ..
393            } => {
394                if expected.is_some()
395                    || *tokens_read == 0
396                    || tokens_read > tokens_total
397                    || *tokens_read > limits_value.tokens
398                {
399                    return Err(invalid_output());
400                }
401                check_vector(vector, ready)?;
402            }
403            Outcome::Windows {
404                windows: made,
405                tokens_total,
406                ..
407            } => {
408                let Some(asked) = windows.filter(|_| expected.is_none()) else {
409                    return Err(invalid_output());
410                };
411                check_windows(made, *tokens_total, &input.text, ready, limits_value, asked)?;
412            }
413        }
414    }
415    Ok(())
416}