Skip to main content

haematite_cli/serve/embed/
job.rs

1//! V7a (ruled 5 Oct 08:48): one page of the embedding job as three steps, so
2//! the service makes the worker call holding no storage admission.
3//!
4//! 1. `Storage::embed_pending`, a shared read: the next page of the walk.
5//! 2. [`make`]: the worker's vectors for the page's texts, sent in batches of
6//!    the worker's batch size. It touches no storage.
7//! 3. `Storage::embed_commit`: the page and the job's position, one commit.
8//!
9//! [`step`] runs the three in order, for tests and for V7b to split across its
10//! locks. A page with no text to send calls the worker not at all and still
11//! commits, so the position moves past removals and records with no text. A
12//! failed or refused worker call commits nothing: the position and the
13//! companion are unchanged, the failure's name is returned, and the next step
14//! takes the same page. There is no retry here; when to try again is the
15//! service's (V7b).
16//!
17//! EMB1 (Tom: never trim below what the model reads, never throw data away):
18//! a text the model reads only part of has no vector. The page names it,
19//! with its tokens and the model's limit, commits the rest of its batch, and
20//! the walk goes past it, so it is not sent again until its text changes.
21//!
22//! EMB-E1: a text the worker would refuse (`embed_input_empty`, or too long
23//! in bytes) is never put in a batch, since one refused text refuses its
24//! whole request. It is passed with no vector, named, and the walk goes on.
25//!
26//! EMB-E2: with windows on, a text longer than the model reads comes back as
27//! its windows, each vector stored with its span; one past the window cap
28//! comes back `windows_over_cap` and, as EMB1, gets no vector.
29
30use std::num::NonZeroUsize;
31use std::time::Duration;
32
33use haem_frames::embedding::{ItemCode, Kind, input_refusal};
34
35use super::{Answer, Embedded, Embedder, Longer};
36use crate::serve::protocol::{Selection, Version};
37use crate::serve::storage::embed_job::{Made, MadeWindows, Page, Reach, Window};
38use crate::serve::storage::embed_pending::{Pending, Waiting};
39use crate::serve::storage::embedded::{Declaration, ModelName};
40use crate::serve::storage::{Failure, Storage};
41
42/// Texts in, vectors out: what the job needs of a worker.
43pub trait Embed {
44    /// The vectors of `texts` as documents, in the order given, and the
45    /// model that made them.
46    ///
47    /// # Errors
48    /// Whatever the worker answers.
49    fn documents(&self, texts: &[String]) -> Result<Embedded, Failure>;
50}
51
52impl Embed for Embedder {
53    fn documents(&self, texts: &[String]) -> Result<Embedded, Failure> {
54        self.embed(Kind::Document, texts)
55    }
56}
57
58/// The job's bounds, from the service's settings.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct Bounds {
61    /// Records one page names; one commit each.
62    pub page_records: NonZeroUsize,
63    /// Texts one worker call takes (`embed_batch_items`).
64    pub batch: NonZeroUsize,
65    /// The longest text the worker takes (`embed_input_bytes`).
66    pub input_bytes: NonZeroUsize,
67    /// HANG1: the most one worker call may take (`embed_call_ms`).
68    pub call: Duration,
69}
70
71/// A record whose text has more tokens than the model reads: no vector is
72/// made of it (EMB1), as none is of a text too long in bytes (`too_long`).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct LongerThanModel {
75    pub id: String,
76    /// The text's content tokens, without special or prefix tokens.
77    pub tokens_total: u64,
78    /// The model's limit, as its Ready declared it.
79    pub tokens_limit: u64,
80}
81
82/// A page made: what its commit writes, and the records it made no vector
83/// of because the model reads only part of their text, or the worker would
84/// refuse it.
85#[derive(Debug, Clone, PartialEq)]
86pub struct MadePage {
87    pub page: Page,
88    pub longer_than_model: Vec<LongerThanModel>,
89    /// EMB-E1: records whose text is empty or only whitespace, never sent.
90    pub empty_input: Vec<String>,
91    /// Records whose text is longer in bytes than a worker takes, never sent.
92    pub too_long: Vec<String>,
93    /// EMB-E2: records whose whole cover needs more windows than the cap,
94    /// given no vector.
95    pub windows_over_cap: Vec<LongerThanModel>,
96}
97
98/// What one committed page held.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct Counts {
101    pub made: usize,
102    pub removed: usize,
103    pub no_text: usize,
104    pub too_long: usize,
105    pub longer_than_model: usize,
106    /// EMB-E1: records passed because their text is empty or whitespace.
107    pub empty_input: usize,
108    /// EMB-E2: records embedded whole as windows.
109    pub windowed: usize,
110    /// EMB-E2: records past the window cap, given no vector.
111    pub windows_over_cap: usize,
112    /// The page ended its walk.
113    pub ended: bool,
114}
115
116/// What one step did.
117#[derive(Debug, Clone)]
118pub enum Step {
119    /// Nothing pending: the job's position is the head.
120    Idle,
121    /// One page committed.
122    Committed(Counts),
123    /// The worker failed or was refused, by this name; nothing committed.
124    Failed(Failure),
125}
126
127/// The page of `pending`: its texts' vectors and its removals.
128///
129/// The vectors come from `worker` in batches of `bounds.batch`. Its texts the
130/// model reads only part of, or the worker would refuse, are named and given
131/// no vector.
132///
133/// # Errors
134/// The worker's failure; `embed_vector_count` for an answer holding another
135/// number of vectors than texts sent; `embed_vector_length` for a vector of
136/// another length than declared; `embed_model_mismatch` for vectors of
137/// another model than the declared one. Each is the worker's failing, so
138/// [`step`] gives it as [`Step::Failed`], never as a storage error.
139pub fn make(
140    pending: &Pending,
141    collection: &str,
142    field: &str,
143    declaration: &Declaration,
144    worker: &dyn Embed,
145    bounds: Bounds,
146) -> Result<MadePage, Failure> {
147    let Sorted {
148        texts,
149        removed,
150        empty_input,
151        too_long,
152    } = sorted(pending, bounds.input_bytes);
153    // RED4: a record too long in bytes, whose source is no longer a string
154    // (P2a), or whose text is now empty (EMB-E1) keeps no vector of an
155    // earlier text.
156    let cleared: Vec<String> = too_long
157        .iter()
158        .chain(&pending.no_text)
159        .chain(&empty_input)
160        .cloned()
161        .collect();
162    let mut collected = Collected {
163        cleared,
164        ..Collected::default()
165    };
166    for chunk in texts.chunks(bounds.batch.get()) {
167        let sent: Vec<String> = chunk.iter().map(|(_, _, text)| (*text).clone()).collect();
168        let answers = checked(worker.documents(&sent)?, sent.len(), declaration)?;
169        for ((id, made_from, _), answer) in chunk.iter().zip(answers) {
170            collected.take(id, *made_from, answer);
171        }
172    }
173    let page = Page {
174        collection: collection.to_owned(),
175        field: field.to_owned(),
176        model: declaration.model.clone(),
177        made: collected.made,
178        windowed: collected.windowed,
179        removed,
180        cleared: collected.cleared,
181        toward: pending.toward,
182        reach: pending.reach.clone(),
183    };
184    Ok(MadePage {
185        page,
186        longer_than_model: collected.longer_than_model,
187        empty_input,
188        too_long,
189        windows_over_cap: collected.windows_over_cap,
190    })
191}
192
193/// One call's answers, checked: one for each text sent, every vector and
194/// window of the declared length, made by the declared model.
195///
196/// # Errors
197/// `embed_vector_count`, `embed_vector_length` or `embed_model_mismatch`.
198fn checked(
199    embedded: Embedded,
200    sent: usize,
201    declaration: &Declaration,
202) -> Result<Vec<Answer>, Failure> {
203    if embedded.answers.len() != sent {
204        return Err(Failure::new(
205            "embed_vector_count",
206            format!(
207                "{sent} texts were sent and {} vectors came back",
208                embedded.answers.len()
209            ),
210        ));
211    }
212    let dimensions = declaration.dimensions.get();
213    let wrong = embedded
214        .answers
215        .iter()
216        .flat_map(|answer| match answer {
217            Answer::Vector(vector) => vec![vector.len()],
218            Answer::Windows(windows) => windows.iter().map(|window| window.vector.len()).collect(),
219            Answer::Longer(_) | Answer::OverCap(_) => Vec::new(),
220        })
221        .find(|length| *length != dimensions);
222    if let Some(wrong) = wrong {
223        return Err(Failure::new(
224            "embed_vector_length",
225            format!("a vector of {wrong} members came back; {dimensions} are declared"),
226        ));
227    }
228    let model = ModelName {
229        name: embedded.model.name,
230        revision: embedded.model.revision,
231        manifest_sha256: embedded.model.manifest_sha256,
232    };
233    if model != declaration.model {
234        return Err(Failure::new(
235            "embed_model_mismatch",
236            format!(
237                "the worker runs {}; the field is declared for {}",
238                model.name, declaration.model.name
239            ),
240        ));
241    }
242    Ok(embedded.answers)
243}
244
245/// What a page's answers made: its vectors, its windowed records (EMB-E2),
246/// and the records given no vector, each named.
247#[derive(Default)]
248struct Collected {
249    made: Vec<Made>,
250    windowed: Vec<MadeWindows>,
251    cleared: Vec<String>,
252    longer_than_model: Vec<LongerThanModel>,
253    windows_over_cap: Vec<LongerThanModel>,
254}
255
256impl Collected {
257    /// One record's answer. A text the model reads only part of, or one past
258    /// the window cap, gets no vector, and any earlier one is cleared.
259    fn take(&mut self, id: &str, made_from: Version, answer: Answer) {
260        let named = |longer: Longer| LongerThanModel {
261            id: id.to_owned(),
262            tokens_total: longer.tokens_total,
263            tokens_limit: longer.tokens_limit,
264        };
265        match answer {
266            Answer::Vector(vector) => self.made.push(Made {
267                id: id.to_owned(),
268                made_from,
269                vector,
270            }),
271            Answer::Windows(windows) => self.windowed.push(MadeWindows {
272                id: id.to_owned(),
273                made_from,
274                windows: windows
275                    .into_iter()
276                    .map(|window| Window {
277                        start: window.start,
278                        end: window.end,
279                        vector: window.vector,
280                    })
281                    .collect(),
282            }),
283            Answer::Longer(longer) => {
284                self.cleared.push(id.to_owned());
285                self.longer_than_model.push(named(longer));
286            }
287            Answer::OverCap(longer) => {
288                self.cleared.push(id.to_owned());
289                self.windows_over_cap.push(named(longer));
290            }
291        }
292    }
293}
294
295/// A page's texts to send, its removals, and the texts the worker would
296/// refuse, each named once.
297struct Sorted<'page> {
298    texts: Vec<(&'page String, Version, &'page String)>,
299    removed: Vec<String>,
300    empty_input: Vec<String>,
301    too_long: Vec<String>,
302}
303
304/// EMB-E1: `pending`'s texts, each checked as the worker checks a request
305/// before any batch is built, so a text it would refuse is never sent and
306/// never refuses its batch-mates. Those the walk already passed come first.
307fn sorted(pending: &Pending, input_bytes: NonZeroUsize) -> Sorted<'_> {
308    let limit = u64::try_from(input_bytes.get()).unwrap_or(u64::MAX);
309    let mut sorted = Sorted {
310        texts: Vec::new(),
311        removed: Vec::new(),
312        empty_input: pending.empty_input.clone(),
313        too_long: pending.too_long.clone(),
314    };
315    for waiting in &pending.waiting {
316        match waiting {
317            Waiting::Text {
318                id,
319                made_from,
320                text,
321            } => match input_refusal(text, limit) {
322                None => sorted.texts.push((id, *made_from, text)),
323                Some(ItemCode::EmbedInputEmpty) => sorted.empty_input.push(id.clone()),
324                Some(_) => {
325                    sorted.too_long.push(id.clone());
326                }
327            },
328            Waiting::Removed { id } => sorted.removed.push(id.clone()),
329        }
330    }
331    sorted
332}
333
334/// One page of the job on `branch`'s `collection.field`: pending, made,
335/// committed.
336///
337/// # Errors
338/// Whatever reading the walk or committing the page answers; a worker's
339/// failure is not an error but [`Step::Failed`].
340pub fn step(
341    storage: &mut Storage,
342    branch: &str,
343    collection: &str,
344    field: &str,
345    worker: &dyn Embed,
346    bounds: Bounds,
347) -> Result<Step, Failure> {
348    let pending = storage.embed_pending(
349        branch,
350        collection,
351        field,
352        bounds.page_records,
353        bounds.input_bytes,
354    )?;
355    let Some(pending) = pending else {
356        return Ok(Step::Idle);
357    };
358    let at = storage.capture(&Selection {
359        branch: branch.to_owned(),
360        version: None,
361    })?;
362    let (declaration, _companion) = storage.embedded_field(&at, collection, field)?;
363    let made = make(&pending, collection, field, &declaration, worker, bounds);
364    let made = match made {
365        Ok(made) => made,
366        Err(failure) => return Ok(Step::Failed(failure)),
367    };
368    let page = &made.page;
369    storage.embed_commit(branch, page, bounds.page_records)?;
370    Ok(Step::Committed(Counts {
371        made: page.made.len(),
372        removed: page.removed.len(),
373        no_text: pending.no_text.len(),
374        too_long: made.too_long.len(),
375        // EMB-E2 (i): a text past the cap is one the model reads part of.
376        longer_than_model: made
377            .longer_than_model
378            .len()
379            .saturating_add(made.windows_over_cap.len()),
380        empty_input: made.empty_input.len(),
381        windowed: page.windowed.len(),
382        windows_over_cap: made.windows_over_cap.len(),
383        ended: page.reach == Reach::End,
384    }))
385}
386
387#[cfg(test)]
388#[path = "job_tests.rs"]
389mod tests;