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    narrow_resolved(pending, hash_of, selection, words, silent, || {
99        selection.resolve(conn, ctx)
100    })
101}
102
103/// The directory-local twin of [`narrow`]: the selection is resolved through
104/// [`RowSelection::resolve_in`](crate::selection::RowSelection::resolve_in), so
105/// every `--path` is guarded against the selected root before any work.
106#[allow(clippy::too_many_arguments)]
107pub fn narrow_in<T>(
108    pending: Vec<T>,
109    hash_of: impl Fn(&T) -> &str,
110    selection: &RowSelection,
111    conn: &Connection,
112    ctx: &SelectionCtx,
113    library: &crate::library::LibraryContext,
114    words: Words,
115    silent: bool,
116) -> Result<Work<T>> {
117    narrow_resolved(pending, hash_of, selection, words, silent, || {
118        selection.resolve_in(conn, ctx, library)
119    })
120}
121
122fn narrow_resolved<T>(
123    pending: Vec<T>,
124    hash_of: impl Fn(&T) -> &str,
125    selection: &RowSelection,
126    words: Words,
127    silent: bool,
128    resolve: impl FnOnce() -> Result<crate::selection::Resolved>,
129) -> Result<Work<T>> {
130    if pending.is_empty() {
131        return Ok(Work::Nothing(match words.nothing_pending {
132            Some(m) => m.to_string(),
133            None => format!(
134                "Nothing to {}: everything eligible is already done.",
135                words.verb
136            ),
137        }));
138    }
139
140    let eligible = pending.len();
141    let items = if selection.is_empty() {
142        pending
143    } else {
144        let resolved = resolve()?;
145        match resolved.hashes {
146            // `None` means the selection put no constraint on hashes, so
147            // everything pending survives. It does NOT mean "matched nothing":
148            // collapsing the two would turn a typo into a full-library run.
149            None => pending,
150            Some(h) => pending
151                .into_iter()
152                .filter(|item| h.contains(hash_of(item)))
153                .collect(),
154        }
155    };
156
157    if !selection.is_empty() && !silent {
158        // Said before the work, not after. A command that quietly processes a
159        // fraction of the library is the truncation bug of 0.14.1 with a much
160        // longer feedback loop.
161        eprintln!(
162            "{} {} of {} pending item(s) ({})",
163            words.gerund,
164            items.len(),
165            eligible,
166            selection.describe()
167        );
168    }
169
170    if items.is_empty() {
171        return Ok(Work::Nothing(format!(
172            "Nothing to {}: the selection matched nothing pending.",
173            words.verb
174        )));
175    }
176
177    Ok(Work::Some(Pending { items, eligible }))
178}
179
180/// Runs `f` only when there is work, printing the reason when there is not.
181///
182/// This is the point of the module. A caller cannot load a model unless it is
183/// inside `f`, so no future command can reach a 778MB download by getting a
184/// guard wrong: there is no code path from `Work::Nothing` to the closure.
185///
186/// Returns `None` when `f` did not run, for callers that need to tell the
187/// difference. Most do not and can ignore it.
188pub fn with_work<T, R>(
189    work: Work<T>,
190    silent: bool,
191    f: impl FnOnce(Pending<T>) -> Result<R>,
192) -> Result<Option<R>> {
193    match work {
194        Work::Nothing(msg) => {
195            if !silent {
196                eprintln!("{msg}");
197            }
198            Ok(None)
199        }
200        Work::Some(pending) => f(pending).map(Some),
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::cell::Cell;
208
209    const W: Words = Words::new("embed", "Embedding");
210
211    fn conn() -> Connection {
212        Connection::open_in_memory().unwrap()
213    }
214
215    /// A library with one jpg and one mov, so a selection can actually match
216    /// and actually miss. Mirrors the fixture in `selection.rs`.
217    fn db() -> Connection {
218        let c = Connection::open_in_memory().unwrap();
219        c.execute_batch(
220            "CREATE TABLE file_hashes (
221                path TEXT PRIMARY KEY, hash TEXT NOT NULL, size_bytes INTEGER,
222                created_at TEXT, modified_at TEXT, ext TEXT, mime TEXT, phash INTEGER,
223                exif_date TEXT, gps_lat REAL, gps_lon REAL, width INTEGER, height INTEGER);
224             INSERT INTO file_hashes (path, hash, ext, mime) VALUES
225               ('/lib/a.jpg','h_jpg','jpg','image/jpeg'),
226               ('/lib/b.mov','h_mov','mov','video/quicktime');",
227        )
228        .unwrap();
229        c
230    }
231
232    fn hash(s: &String) -> &str {
233        s.as_str()
234    }
235
236    #[test]
237    fn an_empty_pending_set_is_nothing_to_do() {
238        let c = conn();
239        let w = narrow(
240            Vec::<String>::new(),
241            hash,
242            &RowSelection::default(),
243            &c,
244            &SelectionCtx::default(),
245            W,
246            true,
247        )
248        .unwrap();
249        match w {
250            Work::Nothing(m) => {
251                assert_eq!(m, "Nothing to embed: everything eligible is already done.")
252            }
253            Work::Some(_) => panic!("an empty pending set must not be work"),
254        }
255    }
256
257    #[test]
258    fn no_selection_leaves_the_pending_set_untouched() {
259        let c = conn();
260        let w = narrow(
261            vec!["a".to_string(), "b".to_string()],
262            hash,
263            &RowSelection::default(),
264            &c,
265            &SelectionCtx::default(),
266            W,
267            true,
268        )
269        .unwrap();
270        match w {
271            Work::Some(p) => {
272                assert_eq!(p.items.len(), 2);
273                assert_eq!(p.eligible, 2, "eligible is the count before narrowing");
274            }
275            Work::Nothing(m) => panic!("unfiltered work was dropped: {m}"),
276        }
277    }
278
279    #[test]
280    fn a_caller_may_supply_its_own_empty_wording() {
281        // The default sentence suits embed and classify. faces needs a
282        // different one, and passing it in keeps the message inside the helper
283        // rather than sending callers back to printing their own.
284        let c = conn();
285        let w = narrow(
286            Vec::<String>::new(),
287            hash,
288            &RowSelection::default(),
289            &c,
290            &SelectionCtx::default(),
291            W.saying("All hashes already processed."),
292            true,
293        )
294        .unwrap();
295        match w {
296            Work::Nothing(m) => assert_eq!(m, "All hashes already processed."),
297            Work::Some(_) => panic!("an empty pending set must not be work"),
298        }
299    }
300
301    #[test]
302    fn a_selection_that_matches_nothing_is_nothing_to_do() {
303        // The pending set is non-empty and the filter excludes all of it. This
304        // must report "your filter matched nothing", not "you are up to date":
305        // the two call for opposite reactions from the reader.
306        let c = db();
307        let mut s = RowSelection::default();
308        s.exts = vec!["png".to_string()]; // present in neither row
309        let w = narrow(
310            vec!["h_jpg".to_string(), "h_mov".to_string()],
311            hash,
312            &s,
313            &c,
314            &SelectionCtx::default(),
315            W,
316            true,
317        )
318        .unwrap();
319        match w {
320            Work::Nothing(m) => {
321                assert_eq!(
322                    m,
323                    "Nothing to embed: the selection matched nothing pending."
324                )
325            }
326            Work::Some(p) => panic!("{} item(s) survived a filter matching none", p.items.len()),
327        }
328    }
329
330    #[test]
331    fn a_selection_keeps_only_what_it_matched() {
332        let c = db();
333        let mut s = RowSelection::default();
334        s.exts = vec!["jpg".to_string()];
335        let w = narrow(
336            vec!["h_jpg".to_string(), "h_mov".to_string()],
337            hash,
338            &s,
339            &c,
340            &SelectionCtx::default(),
341            W,
342            true,
343        )
344        .unwrap();
345        match w {
346            Work::Some(p) => {
347                assert_eq!(p.items, vec!["h_jpg".to_string()]);
348                assert_eq!(p.eligible, 2, "the denominator is the pre-filter count");
349            }
350            Work::Nothing(m) => panic!("a matching filter dropped everything: {m}"),
351        }
352    }
353
354    #[test]
355    fn the_closure_never_runs_when_there_is_nothing_to_do() {
356        // The regression guard for the incident this module exists to prevent.
357        // The model load lives inside the closure, so this assertion is what
358        // makes a download unreachable with nothing to process.
359        let ran = Cell::new(false);
360        let out = with_work(Work::<String>::Nothing("nothing".into()), true, |_| {
361            ran.set(true);
362            Ok(())
363        })
364        .unwrap();
365        assert!(
366            !ran.get(),
367            "no work must mean no closure, and so no model load"
368        );
369        assert!(out.is_none());
370    }
371
372    #[test]
373    fn the_closure_runs_and_returns_its_value_when_there_is_work() {
374        let ran = Cell::new(false);
375        let out = with_work(
376            Work::Some(Pending {
377                items: vec!["a".to_string()],
378                eligible: 1,
379            }),
380            true,
381            |p| {
382                ran.set(true);
383                Ok(p.items.len())
384            },
385        )
386        .unwrap();
387        assert!(ran.get());
388        assert_eq!(out, Some(1));
389    }
390
391    #[test]
392    fn an_error_from_the_closure_is_not_swallowed() {
393        let out = with_work(
394            Work::Some(Pending {
395                items: vec!["a".to_string()],
396                eligible: 1,
397            }),
398            true,
399            |_| -> Result<()> { anyhow::bail!("boom") },
400        );
401        assert!(
402            out.is_err(),
403            "the closure's failure is the caller's failure"
404        );
405    }
406}