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}