Skip to main content

videre_core/
work.rs

1//! One place that decides whether there is work, and therefore whether a model
2//! is ever loaded.
3//!
4//! `embed`, `classify` and `faces` each used to hand-write the same shape:
5//! compute a pending set, return early with a message if it is empty, narrow it
6//! by the selection, print `N of M`, return early again if that emptied it, and
7//! only then load a model.
8//!
9//! Three copies meant no single test could cover the behaviour, and one of them
10//! was wrong: `classify`'s early return was never actually taken, so the command
11//! reached `Embedder::load` and downloaded 778MB of model weights from inside a
12//! unit test. On CI that woke an inference test which had always skipped, and
13//! took the Ubuntu job from ~3 minutes to nearly 40.
14//!
15//! `with_work` is the structural half of the fix: the model load lives inside a
16//! closure that only runs when there is work, so "nothing to do" cannot reach a
17//! download however wrong a future guard is.
18
19use crate::selection::{RowSelection, SelectionCtx};
20use anyhow::Result;
21use rusqlite::Connection;
22
23/// A pending set that survived the selection, plus how large it was before.
24///
25/// `eligible` is kept so the caller can say `N of M`. Without the denominator a
26/// filter that matched nothing and an empty library look identical.
27pub struct Pending<T> {
28    pub items: Vec<T>,
29    pub eligible: usize,
30}
31
32/// Either there is work, or there is a reason there is not.
33pub enum Work<T> {
34    /// Nothing to do. Carries the message to show, already assembled.
35    Nothing(String),
36    Some(Pending<T>),
37}
38
39/// The verb a command uses for its own work.
40///
41/// Deliberately no item noun. `embed` counted "pending file(s)", `classify`
42/// "pending hash(es)" and `faces` paths, which is three names for one idea and
43/// a difference no user cares about. They are all `item(s)` now; carrying the
44/// distinction as a parameter would have preserved the divergence and called it
45/// configuration.
46#[derive(Clone, Copy)]
47pub struct Words {
48    /// Lowercase, as it appears mid-sentence: `embed`, `classify`, `process`.
49    pub verb: &'static str,
50    /// Capitalised, as it starts a line: `Embedding`, `Classifying`.
51    pub gerund: &'static str,
52    /// Replaces the default "Nothing to <verb>: everything eligible is already
53    /// done." when a command has a state that sentence would describe wrongly.
54    ///
55    /// The default is right for `embed` and `classify`, where an empty pending
56    /// set really does mean "you are up to date". It is wrong for `faces`,
57    /// whose empty set can also mean "nothing here has a face to look for", and
58    /// which has its own established wording. Passing the sentence in beats
59    /// either forcing one wording on every caller or letting callers print
60    /// their own and drift apart again.
61    pub nothing_pending: Option<&'static str>,
62}
63
64impl Words {
65    pub const fn new(verb: &'static str, gerund: &'static str) -> Self {
66        Words {
67            verb,
68            gerund,
69            nothing_pending: None,
70        }
71    }
72
73    /// Supplies this command's own wording for the empty-pending case.
74    pub const fn saying(mut self, nothing_pending: &'static str) -> Self {
75        self.nothing_pending = Some(nothing_pending);
76        self
77    }
78}
79
80/// Narrows `pending` by `selection`, reporting as it goes.
81///
82/// Returns `Work::Nothing` when there is nothing to do, either because the
83/// pending set was empty to begin with or because the selection emptied it. The
84/// two cases carry different messages, since "you are up to date" and "your
85/// filter matched nothing" call for different reactions from the reader.
86///
87/// `hash_of` reads an item's hash, so this works for any pending type: `embed`
88/// passes rows, `faces` passes paths.
89pub fn narrow<T>(
90    pending: Vec<T>,
91    hash_of: impl Fn(&T) -> &str,
92    selection: &RowSelection,
93    conn: &Connection,
94    ctx: &SelectionCtx,
95    words: Words,
96    silent: bool,
97) -> Result<Work<T>> {
98    if pending.is_empty() {
99        return Ok(Work::Nothing(match words.nothing_pending {
100            Some(m) => m.to_string(),
101            None => format!(
102                "Nothing to {}: everything eligible is already done.",
103                words.verb
104            ),
105        }));
106    }
107
108    let eligible = pending.len();
109    let items = if selection.is_empty() {
110        pending
111    } else {
112        let resolved = selection.resolve(conn, ctx)?;
113        match resolved.hashes {
114            // `None` means the selection put no constraint on hashes, so
115            // everything pending survives. It does NOT mean "matched nothing":
116            // collapsing the two would turn a typo into a full-library run.
117            None => pending,
118            Some(h) => pending
119                .into_iter()
120                .filter(|item| h.contains(hash_of(item)))
121                .collect(),
122        }
123    };
124
125    if !selection.is_empty() && !silent {
126        // Said before the work, not after. A command that quietly processes a
127        // fraction of the library is the truncation bug of 0.14.1 with a much
128        // longer feedback loop.
129        eprintln!(
130            "{} {} of {} pending item(s) ({})",
131            words.gerund,
132            items.len(),
133            eligible,
134            selection.describe()
135        );
136    }
137
138    if items.is_empty() {
139        return Ok(Work::Nothing(format!(
140            "Nothing to {}: the selection matched nothing pending.",
141            words.verb
142        )));
143    }
144
145    Ok(Work::Some(Pending { items, eligible }))
146}
147
148/// Runs `f` only when there is work, printing the reason when there is not.
149///
150/// This is the point of the module. A caller cannot load a model unless it is
151/// inside `f`, so no future command can reach a 778MB download by getting a
152/// guard wrong: there is no code path from `Work::Nothing` to the closure.
153///
154/// Returns `None` when `f` did not run, for callers that need to tell the
155/// difference. Most do not and can ignore it.
156pub fn with_work<T, R>(
157    work: Work<T>,
158    silent: bool,
159    f: impl FnOnce(Pending<T>) -> Result<R>,
160) -> Result<Option<R>> {
161    match work {
162        Work::Nothing(msg) => {
163            if !silent {
164                eprintln!("{msg}");
165            }
166            Ok(None)
167        }
168        Work::Some(pending) => f(pending).map(Some),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use std::cell::Cell;
176
177    const W: Words = Words::new("embed", "Embedding");
178
179    fn conn() -> Connection {
180        Connection::open_in_memory().unwrap()
181    }
182
183    /// A library with one jpg and one mov, so a selection can actually match
184    /// and actually miss. Mirrors the fixture in `selection.rs`.
185    fn db() -> Connection {
186        let c = Connection::open_in_memory().unwrap();
187        c.execute_batch(
188            "CREATE TABLE file_hashes (
189                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
190                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
191                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
192             INSERT INTO file_hashes (path, hash, ext, mime) VALUES
193               ('/lib/a.jpg','h_jpg','jpg','image/jpeg'),
194               ('/lib/b.mov','h_mov','mov','video/quicktime');",
195        )
196        .unwrap();
197        c
198    }
199
200    fn hash(s: &String) -> &str {
201        s.as_str()
202    }
203
204    #[test]
205    fn an_empty_pending_set_is_nothing_to_do() {
206        let c = conn();
207        let w = narrow(
208            Vec::<String>::new(),
209            hash,
210            &RowSelection::default(),
211            &c,
212            &SelectionCtx::default(),
213            W,
214            true,
215        )
216        .unwrap();
217        match w {
218            Work::Nothing(m) => {
219                assert_eq!(m, "Nothing to embed: everything eligible is already done.")
220            }
221            Work::Some(_) => panic!("an empty pending set must not be work"),
222        }
223    }
224
225    #[test]
226    fn no_selection_leaves_the_pending_set_untouched() {
227        let c = conn();
228        let w = narrow(
229            vec!["a".to_string(), "b".to_string()],
230            hash,
231            &RowSelection::default(),
232            &c,
233            &SelectionCtx::default(),
234            W,
235            true,
236        )
237        .unwrap();
238        match w {
239            Work::Some(p) => {
240                assert_eq!(p.items.len(), 2);
241                assert_eq!(p.eligible, 2, "eligible is the count before narrowing");
242            }
243            Work::Nothing(m) => panic!("unfiltered work was dropped: {m}"),
244        }
245    }
246
247    #[test]
248    fn a_caller_may_supply_its_own_empty_wording() {
249        // The default sentence suits embed and classify. faces needs a
250        // different one, and passing it in keeps the message inside the helper
251        // rather than sending callers back to printing their own.
252        let c = conn();
253        let w = narrow(
254            Vec::<String>::new(),
255            hash,
256            &RowSelection::default(),
257            &c,
258            &SelectionCtx::default(),
259            W.saying("All hashes already processed."),
260            true,
261        )
262        .unwrap();
263        match w {
264            Work::Nothing(m) => assert_eq!(m, "All hashes already processed."),
265            Work::Some(_) => panic!("an empty pending set must not be work"),
266        }
267    }
268
269    #[test]
270    fn a_selection_that_matches_nothing_is_nothing_to_do() {
271        // The pending set is non-empty and the filter excludes all of it. This
272        // must report "your filter matched nothing", not "you are up to date":
273        // the two call for opposite reactions from the reader.
274        let c = db();
275        let mut s = RowSelection::default();
276        s.exts = vec!["png".to_string()]; // present in neither row
277        let w = narrow(
278            vec!["h_jpg".to_string(), "h_mov".to_string()],
279            hash,
280            &s,
281            &c,
282            &SelectionCtx::default(),
283            W,
284            true,
285        )
286        .unwrap();
287        match w {
288            Work::Nothing(m) => {
289                assert_eq!(
290                    m,
291                    "Nothing to embed: the selection matched nothing pending."
292                )
293            }
294            Work::Some(p) => panic!("{} item(s) survived a filter matching none", p.items.len()),
295        }
296    }
297
298    #[test]
299    fn a_selection_keeps_only_what_it_matched() {
300        let c = db();
301        let mut s = RowSelection::default();
302        s.exts = vec!["jpg".to_string()];
303        let w = narrow(
304            vec!["h_jpg".to_string(), "h_mov".to_string()],
305            hash,
306            &s,
307            &c,
308            &SelectionCtx::default(),
309            W,
310            true,
311        )
312        .unwrap();
313        match w {
314            Work::Some(p) => {
315                assert_eq!(p.items, vec!["h_jpg".to_string()]);
316                assert_eq!(p.eligible, 2, "the denominator is the pre-filter count");
317            }
318            Work::Nothing(m) => panic!("a matching filter dropped everything: {m}"),
319        }
320    }
321
322    #[test]
323    fn the_closure_never_runs_when_there_is_nothing_to_do() {
324        // The regression guard for the incident this module exists to prevent.
325        // The model load lives inside the closure, so this assertion is what
326        // makes a download unreachable with nothing to process.
327        let ran = Cell::new(false);
328        let out = with_work(Work::<String>::Nothing("nothing".into()), true, |_| {
329            ran.set(true);
330            Ok(())
331        })
332        .unwrap();
333        assert!(
334            !ran.get(),
335            "no work must mean no closure, and so no model load"
336        );
337        assert!(out.is_none());
338    }
339
340    #[test]
341    fn the_closure_runs_and_returns_its_value_when_there_is_work() {
342        let ran = Cell::new(false);
343        let out = with_work(
344            Work::Some(Pending {
345                items: vec!["a".to_string()],
346                eligible: 1,
347            }),
348            true,
349            |p| {
350                ran.set(true);
351                Ok(p.items.len())
352            },
353        )
354        .unwrap();
355        assert!(ran.get());
356        assert_eq!(out, Some(1));
357    }
358
359    #[test]
360    fn an_error_from_the_closure_is_not_swallowed() {
361        let out = with_work(
362            Work::Some(Pending {
363                items: vec!["a".to_string()],
364                eligible: 1,
365            }),
366            true,
367            |_| -> Result<()> { anyhow::bail!("boom") },
368        );
369        assert!(
370            out.is_err(),
371            "the closure's failure is the caller's failure"
372        );
373    }
374}