diff --git a/core/dr-catalog/src/face_shard.rs b/core/dr-catalog/src/face_shard.rs index a1e96d5..ddb872a 100644 --- a/core/dr-catalog/src/face_shard.rs +++ b/core/dr-catalog/src/face_shard.rs @@ -52,6 +52,16 @@ pub const SHARD_MAX_BYTES: u64 = dr_thumbs::SHARD_MAX_BYTES; /// the file after each insert would mean a `VACUUM` to get an honest answer. const BYTES_PER_FACE: u64 = 1024 + 40 + 64; +/// Bytes a stored crop occupies, near enough to bound a shard by. +/// +/// Measured rather than assumed: a 160 px JPEG of a face at quality 78 runs +/// 4-7 KB, and the cap has to be set from the top of that range or a shard +/// overshoots. It dominates [`BYTES_PER_FACE`] roughly five to one, so a shard +/// that carries crops holds around 3,500 faces where one carrying only +/// embeddings held 22,000 -- the cost of a second device showing People +/// immediately instead of re-fetching every proxy. +const BYTES_PER_CROP: u64 = 7 * 1024; + /// One face as it travels between devices. #[derive(Debug, Clone, PartialEq)] pub struct SharedFace { @@ -66,6 +76,13 @@ pub struct SharedFace { pub confidence: f32, pub embedding: Vec, pub crop_px: f32, + /// The face cut out and encoded, or empty where none was kept. + /// + /// Travels with the face rather than in the catalog snapshot, which is the + /// same bargain the embedding makes and for the same reason: the snapshot + /// is uploaded whole on every sync, and a shard is written once and + /// downloaded once. + pub crop: Vec, } /// A store of face shards, beside the thumbnail store. @@ -135,7 +152,11 @@ impl FaceShardStore { source_edge: u32, faces: &[SharedFace], ) -> Result { - let incoming = faces.len() as u64 * BYTES_PER_FACE + 64; + let incoming = faces + .iter() + .map(|f| BYTES_PER_FACE + if f.crop.is_empty() { 0 } else { BYTES_PER_CROP }) + .sum::() + + 64; let shard = self.active_shard(incoming)?; let conn = self.open_shard(shard, true)?; @@ -150,8 +171,8 @@ impl FaceShardStore { tx.execute( "INSERT INTO faces (file_id, model_id, x, y, w, h, landmarks, confidence, - embedding, crop_px) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + embedding, crop_px, crop) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", rusqlite::params![ f.file_id as i64, f.model_id, @@ -163,6 +184,7 @@ impl FaceShardStore { f.confidence as f64, f.embedding, f.crop_px as f64, + (!f.crop.is_empty()).then_some(f.crop.as_slice()), ], )?; } @@ -314,7 +336,7 @@ impl FaceShardStore { } let mut fq = src.prepare( "SELECT file_id, model_id, x, y, w, h, landmarks, confidence, - embedding, crop_px + embedding, crop_px, crop FROM faces WHERE file_id = ?1 AND model_id = ?2", )?; let faces: Vec = fq @@ -355,7 +377,7 @@ impl FaceShardStore { let Some(edge) = edge else { return Ok(None) }; let mut q = conn.prepare( - "SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px + "SELECT file_id, model_id, x, y, w, h, landmarks, confidence, embedding, crop_px, crop FROM faces WHERE file_id = ?1 AND model_id = ?2", )?; let faces: Vec = q @@ -398,7 +420,7 @@ pub fn export_to_shards( continue; } let mut fq = conn.prepare( - "SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px + "SELECT x, y, w, h, landmarks, detector_confidence, embedding, crop_px, crop FROM faces WHERE image_id = ?1 AND model_id = ?2", )?; let faces: Vec = fq @@ -414,6 +436,7 @@ pub fn export_to_shards( confidence: r.get::<_, f64>(5)? as f32, embedding: r.get(6)?, crop_px: r.get::<_, f64>(7)? as f32, + crop: r.get::<_, Option>>(8)?.unwrap_or_default(), }) })? .collect::>()?; @@ -475,6 +498,10 @@ pub fn import_from_shards( embedding: f.embedding, crop_px: f.crop_px, model_id: f.model_id, + // A peer that indexed before crops existed sends none, and the + // reader falls back to the proxy exactly as it does for a face + // this device indexed that early. + crop: f.crop, }) .collect(); @@ -514,6 +541,7 @@ fn read_shared_face(r: &rusqlite::Row<'_>) -> rusqlite::Result { confidence: r.get::<_, f64>(7)? as f32, embedding: r.get(8)?, crop_px: r.get::<_, f64>(9)? as f32, + crop: r.get::<_, Option>>(10)?.unwrap_or_default(), }) } @@ -585,7 +613,10 @@ CREATE TABLE IF NOT EXISTS faces ( landmarks BLOB NOT NULL, confidence REAL NOT NULL, embedding BLOB NOT NULL, - crop_px REAL NOT NULL + crop_px REAL NOT NULL, + -- The face, cut out. NULL where the face was found before crops were kept, + -- or adopted from a peer that did not have one. + crop BLOB ); CREATE INDEX IF NOT EXISTS faces_file ON faces(file_id, model_id); @@ -635,6 +666,7 @@ mod tests { confidence: 0.87, embedding: vec![seed; 1024], crop_px: 180.0, + crop: vec![seed; 64], } } @@ -842,6 +874,7 @@ mod catalog_round_trip { embedding: vec![seed; 1024], crop_px: 180.0, model_id: "w600k_mbf".into(), + crop: vec![seed; 64], } } diff --git a/core/dr-catalog/src/faces.rs b/core/dr-catalog/src/faces.rs index f89d35c..2212d89 100644 --- a/core/dr-catalog/src/faces.rs +++ b/core/dr-catalog/src/faces.rs @@ -68,6 +68,18 @@ pub struct DetectedFace { /// Which model produced the embedding. Comparing across models is the one /// mistake that yields plausible garbage rather than an error. pub model_id: String, + /// The face itself, cut out and encoded, ready to draw. + /// + /// Cut at detection time because that is the one moment the pixels are + /// already in memory. The alternative -- and what this replaced -- is + /// re-decoding the whole proxy and cutting the box out again every time + /// the People screen opens, which makes the screen a derivative of a cache + /// that is entitled to evict anything at any time. + /// + /// Empty is allowed and means "not cut": a caller with only a box and an + /// embedding, such as a face adopted from a peer's shard that predates + /// crops, stores nothing here and the reader falls back to the proxy. + pub crop: Vec, } /// A stored face, with whatever identity it has acquired. @@ -101,6 +113,13 @@ pub struct Person { pub confirmed_faces: u64, /// Faces the system suggests and the user has not ruled on. pub suggested_faces: u64, + /// The user has looked at this group and does not want to identify it. + /// + /// Distinct from an empty name, which means *not yet looked at*. Most + /// clusters in a real library are strangers, and without somewhere to put + /// that judgement the screen never gets shorter however much work the user + /// does on it. + pub ignored: bool, } /// The FR-CULL-9 calibration, re-exported from where it is fitted. @@ -165,8 +184,8 @@ pub fn record_detections( tx.execute( "INSERT INTO faces (image_id, x, y, w, h, landmarks, detector_confidence, - embedding, crop_px, model_id, detected_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + embedding, crop_px, model_id, detected_at, crop) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ image_id.0 as i64, f.x as f64, @@ -179,6 +198,10 @@ pub fn record_detections( f.crop_px as f64, f.model_id, now, + // NULL rather than an empty blob, so "no crop" is one state in + // the database rather than two the readers each have to know + // about. + (!f.crop.is_empty()).then_some(f.crop.as_slice()), ], )?; let id = FaceId(tx.last_insert_rowid() as u64); @@ -415,7 +438,8 @@ pub fn people(conn: &Connection) -> Result, CatalogError> { let mut q = conn.prepare( "SELECT p.id, p.uuid, p.name, COALESCE(SUM(fp.confirmed = 1), 0), - COALESCE(SUM(fp.confirmed = 0), 0) + COALESCE(SUM(fp.confirmed = 0), 0), + p.ignored FROM people p LEFT JOIN face_person fp ON fp.person_id = p.id WHERE p.merged_into IS NULL @@ -429,6 +453,7 @@ pub fn people(conn: &Connection) -> Result, CatalogError> { name: r.get(2)?, confirmed_faces: r.get::<_, i64>(3)? as u64, suggested_faces: r.get::<_, i64>(4)? as u64, + ignored: r.get(5)?, }) })?; rows.collect::>().map_err(Into::into) @@ -732,6 +757,114 @@ pub fn delete_all_face_data(conn: &Connection) -> Result { Ok(faces as u64) } +/// The stored crops for one person's faces. +/// +/// Read separately from [`for_person`] rather than joined onto it, because the +/// boxes are wanted in several places that do not draw anything — clustering +/// anchors, the develop overlay, the segmentation naming pass — and dragging a +/// few hundred KB of JPEG through those would be pure waste. +/// +/// A face with no stored crop is simply absent from the map; the caller falls +/// back to cutting one out of the proxy. +pub fn crops_for_person( + conn: &Connection, + person: PersonId, + include_suggested: bool, +) -> Result>, CatalogError> { + let mut q = conn.prepare( + "SELECT f.id, f.crop + FROM faces f + JOIN face_person fp ON fp.face_id = f.id + WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1) + AND f.crop IS NOT NULL", + )?; + let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| { + Ok((FaceId(r.get::<_, i64>(0)? as u64), r.get::<_, Vec>(1)?)) + })?; + rows.collect::>().map_err(Into::into) +} + +/// One face's stored crop, if it has one. +pub fn crop(conn: &Connection, face: FaceId) -> Result>, CatalogError> { + conn.query_row( + "SELECT crop FROM faces WHERE id = ?1", + [face.0 as i64], + |r| r.get::<_, Option>>(0), + ) + .optional() + .map(Option::flatten) + .map_err(Into::into) +} + +/// How many of a model's faces still have no stored crop. +/// +/// The measure of how much of the library is still drawing itself the slow way, +/// and what a "re-index to fill these in" prompt would be counting. +pub fn faces_without_crop(conn: &Connection, model_id: &str) -> Result { + conn.query_row( + "SELECT COUNT(*) FROM faces WHERE model_id = ?1 AND crop IS NULL", + [model_id], + |r| r.get::<_, i64>(0), + ) + .map(|n| n as u64) + .map_err(Into::into) +} + +/// The user does not want to identify this person. +/// +/// Reversible, and deliberately not a deletion: deleting the group would only +/// have it rebuilt by the next clustering pass, since the faces are still there +/// and still similar to each other. See the V10 migration note. +pub fn set_ignored(conn: &Connection, person: PersonId, ignored: bool) -> Result<(), CatalogError> { + conn.execute( + "UPDATE people + SET ignored = ?2, revision = revision + 1, modified = ?3 + WHERE id = ?1", + rusqlite::params![person.0 as i64, ignored, now_secs()], + )?; + Ok(()) +} + +/// Whether a person has been set aside. +pub fn is_ignored(conn: &Connection, person: PersonId) -> Result { + conn.query_row( + "SELECT ignored FROM people WHERE id = ?1", + [person.0 as i64], + |r| r.get(0), + ) + .optional() + .map(|v| v.unwrap_or(false)) + .map_err(Into::into) +} + +/// Delete unnamed people who hold no faces at all. +/// +/// Clustering creates a person per unanchored group, and a later pass — a new +/// threshold, more faces indexed, a split undone — can leave the previous run's +/// group with nothing in it. Those are not tombstones and nothing refers to +/// them; left alone they accumulate one rail entry per Regroup, which is what +/// made pressing the button twice look like it had broken the screen. +/// +/// **Named people are never touched, however empty.** A name is user data +/// (FR-CULL-12), and a person the user named and then emptied by moving every +/// face elsewhere is still a person they told us about. Nor is a person that +/// something was merged into, which has to outlive its faces to keep +/// redirecting. +/// +/// Returns how many were removed. +pub fn prune_empty_unnamed(conn: &Connection) -> Result { + let n = conn.execute( + "DELETE FROM people + WHERE name = '' + AND merged_into IS NULL + AND ignored = 0 + AND NOT EXISTS (SELECT 1 FROM face_person fp WHERE fp.person_id = people.id) + AND NOT EXISTS (SELECT 1 FROM people o WHERE o.merged_into = people.id)", + [], + )?; + Ok(n) +} + // ── helpers ─────────────────────────────────────────────────────────────── fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result { @@ -869,16 +1002,17 @@ mod tests { embedding: vec![seed; 1024], crop_px: 180.0, model_id: "w600k_mbf".into(), + crop: Vec::new(), } } #[test] - fn migration_reaches_version_nine() { + fn migration_reaches_the_current_version() { let c = db(); let v: i64 = c .query_row("PRAGMA user_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(v, 9); + assert_eq!(v, crate::schema::SCHEMA_VERSION); } #[test] @@ -1282,4 +1416,168 @@ mod tests { assert_eq!(e[0].2[0], 7); assert!((e[0].3 - 180.0).abs() < 1e-3); } + + // ── stored crops ────────────────────────────────────────────────────── + + fn face_with_crop(seed: u8, crop: Vec) -> DetectedFace { + DetectedFace { crop, ..face(seed) } + } + + #[test] + fn a_crop_stored_with_a_face_comes_back_as_stored() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections( + &c, + img, + "w600k_mbf", + 1024, + &[face_with_crop(3, vec![9; 128])], + ) + .unwrap(); + assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![9; 128])); + } + + /// A face indexed before crops existed has none, and that has to read back + /// as an honest absence rather than an empty image. + #[test] + fn a_face_with_no_crop_reads_back_as_none() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face(3)]).unwrap(); + assert_eq!(crop(&c, ids[0]).unwrap(), None); + assert_eq!(faces_without_crop(&c, "w600k_mbf").unwrap(), 1); + } + + #[test] + fn crops_come_back_per_person_and_skip_the_ones_without() { + let c = db(); + let a = image(&c, 1); + let b = image(&c, 2); + let with = record_detections(&c, a, "w600k_mbf", 1024, &[face_with_crop(1, vec![4; 16])]) + .unwrap()[0]; + let without = record_detections(&c, b, "w600k_mbf", 1024, &[face(2)]).unwrap()[0]; + + let p = create_person(&c, "Anna").unwrap(); + confirm(&c, with, p).unwrap(); + confirm(&c, without, p).unwrap(); + + let crops = crops_for_person(&c, p, true).unwrap(); + assert_eq!(crops.len(), 1, "a face with no crop should not appear"); + assert_eq!(crops.get(&with), Some(&vec![4; 16])); + } + + /// Re-indexing replaces the crop along with everything else, so a better + /// pass over the same photograph updates what the screen draws. + #[test] + fn re_indexing_replaces_the_stored_crop() { + let c = db(); + let img = image(&c, 1); + record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![1; 8])]).unwrap(); + let ids = record_detections(&c, img, "w600k_mbf", 1024, &[face_with_crop(1, vec![2; 8])]) + .unwrap(); + assert_eq!(crop(&c, ids[0]).unwrap(), Some(vec![2; 8])); + } + + // ── setting a person aside ──────────────────────────────────────────── + + #[test] + fn a_person_can_be_ignored_and_un_ignored() { + let c = db(); + let p = create_person(&c, "").unwrap(); + assert!(!is_ignored(&c, p).unwrap()); + + set_ignored(&c, p, true).unwrap(); + assert!(is_ignored(&c, p).unwrap()); + assert!( + people(&c) + .unwrap() + .iter() + .find(|q| q.id == p) + .unwrap() + .ignored + ); + + set_ignored(&c, p, false).unwrap(); + assert!(!is_ignored(&c, p).unwrap()); + } + + /// Ignoring is a judgement, so it bumps the revision the same way a rename + /// does — a device that syncs has to see that something changed. + #[test] + fn ignoring_a_person_bumps_their_revision() { + let c = db(); + let p = create_person(&c, "").unwrap(); + let before: i64 = c + .query_row( + "SELECT revision FROM people WHERE id = ?1", + [p.0 as i64], + |r| r.get(0), + ) + .unwrap(); + set_ignored(&c, p, true).unwrap(); + let after: i64 = c + .query_row( + "SELECT revision FROM people WHERE id = ?1", + [p.0 as i64], + |r| r.get(0), + ) + .unwrap(); + assert!(after > before); + } + + // ── pruning what clustering left behind ─────────────────────────────── + + #[test] + fn an_empty_unnamed_person_is_pruned() { + let c = db(); + create_person(&c, "").unwrap(); + assert_eq!(prune_empty_unnamed(&c).unwrap(), 1); + assert!(people(&c).unwrap().is_empty()); + } + + /// The rule that matters: a name is user data and survives whatever else + /// happens to the group. + #[test] + fn an_empty_named_person_is_kept() { + let c = db(); + create_person(&c, "Anna").unwrap(); + assert_eq!(prune_empty_unnamed(&c).unwrap(), 0); + assert_eq!(people(&c).unwrap().len(), 1); + } + + #[test] + fn an_unnamed_person_with_faces_is_kept() { + let c = db(); + let img = image(&c, 1); + let f = record_detections(&c, img, "w600k_mbf", 1024, &[face(1)]).unwrap()[0]; + let p = create_person(&c, "").unwrap(); + suggest(&c, f, p, 0.95).unwrap(); + assert_eq!(prune_empty_unnamed(&c).unwrap(), 0); + } + + /// An ignored group is empty of nothing — the user ruled on it, and + /// deleting it would bring it straight back on the next Regroup. + #[test] + fn an_ignored_person_is_never_pruned() { + let c = db(); + let p = create_person(&c, "").unwrap(); + set_ignored(&c, p, true).unwrap(); + assert_eq!(prune_empty_unnamed(&c).unwrap(), 0); + } + + /// A merge tombstone has to outlive its faces or the device on the other + /// side of the sync resurrects the person it redirects. + #[test] + fn a_merge_target_is_never_pruned() { + let c = db(); + let target = create_person(&c, "").unwrap(); + let source = create_person(&c, "").unwrap(); + c.execute( + "UPDATE people SET merged_into = ?2 WHERE id = ?1", + rusqlite::params![source.0 as i64, target.0 as i64], + ) + .unwrap(); + assert_eq!(prune_empty_unnamed(&c).unwrap(), 0); + } } diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index 11c5c31..e98bf0e 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 9; +pub const SCHEMA_VERSION: i64 = 10; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -91,6 +91,13 @@ pub fn migrate(conn: &Connection) -> Result { tx.commit()?; } + if from < 10 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V10)?; + tx.pragma_update(None, "user_version", 10)?; + tx.commit()?; + } + Ok(from) } @@ -339,6 +346,50 @@ fn stem_of(path: &str) -> &str { /// Both narrow the walk rather than reorder it, so the index still supplies the /// ordering and SQLite tests the extra predicate per row. That is the cheap /// direction: the expensive part was never the filtering, it was the sort. +const V10: &str = r#" +-- TRACES: FR-CULL-10 | FR-CULL-12 +-- Two columns the People screen turned out to need, and neither is derivable. + +-- A person the user does not want to identify. +-- +-- Most of a real library's clusters are strangers: people in the background of +-- a street, guests at somebody else's party, a face on a poster. They are +-- correctly detected and correctly grouped, and the user will never name any of +-- them -- but they crowd out the handful of groups that matter, and there is no +-- way to tell "not yet looked at" from "looked at, don't care" without +-- recording the second. +-- +-- **User data**, and the reason this is a column rather than a deletion: a +-- deleted cluster comes straight back on the next Regroup, because the faces +-- are still there and still similar. Nothing short of remembering the judgement +-- survives re-clustering, which is the same argument `face_person_rejected` +-- makes one level down (FR-CULL-12). +ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0; + +-- The face, cut out and kept. +-- +-- A face used to be drawn by decoding the 1024px proxy it was found on and +-- cutting the box out again, every time the screen opened. That made the People +-- screen a *derivative of the thumbnail cache*: evict a proxy -- which the +-- cache is entitled to do at any moment -- and the cell goes blank, with no way +-- back short of re-fetching the original over the network and re-detecting it. +-- It also cost one full JPEG decode per image per visit to show a 96px cell. +-- +-- So the crop is cut once, when the pixels are already in hand at detection +-- time, and kept. Small: a 160px JPEG is a few KB, against ~250 KB for the +-- proxy it replaces reading. +-- +-- Nullable, because a face indexed before this column existed has no crop and +-- must still work -- the reader falls back to the old proxy path, and the next +-- indexing pass fills it in. +-- +-- **Stripped from the sync snapshot.** The catalog is uploaded whole, so this +-- would otherwise put tens of MB of JPEG on every sync; crops travel in the +-- face shards instead, which is where the bulk per-face data already goes +-- (`face_shard`). See `sync::snapshot_for_upload`. +ALTER TABLE faces ADD COLUMN crop BLOB; +"#; + const V9: &str = r#" -- TRACES: FR-CULL-8 -- A record that face detection has *run* on an image, distinct from what it diff --git a/core/dr-catalog/src/sync.rs b/core/dr-catalog/src/sync.rs index 01d302d..975d82a 100644 --- a/core/dr-catalog/src/sync.rs +++ b/core/dr-catalog/src/sync.rs @@ -61,6 +61,42 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog // have. The effect is the same: one step, no interleaved writers, no // progress callback. A 50k-image catalog is tens of megabytes. backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?; + drop(backup); + + strip_face_crops(&out)?; + Ok(()) +} + +/// Drop the stored face crops from a snapshot before it is uploaded. +/// +/// The snapshot is the *whole catalog*, uploaded on every sync and downloaded +/// by every device. Face crops are a few KB each and a fully indexed library +/// holds tens of thousands of them, so leaving them in would put tens of MB on +/// every round trip — the exact cost `face_shard`'s 25 MB cap exists to bound, +/// and the reason the bulk per-face data lives in shards in the first place. +/// +/// Crops are not lost by this: they travel in the face shards +/// ([`crate::face_shard::export_to_shards`]), which are written once and +/// downloaded once. Nothing reads a crop out of a merged remote catalog — +/// [`merge_all`] touches collections and keywords only — so removing them here +/// costs a receiving device nothing it would otherwise have had. +/// +/// `VACUUM` afterwards because SQLite does not return freed pages to the file +/// on its own, and an upload sized by the file rather than by its contents +/// would keep paying for bytes that are no longer there. +fn strip_face_crops(snapshot: &Connection) -> Result<(), CatalogError> { + // A catalog older than the crop column is a legitimate input here — a + // snapshot taken mid-migration, or a test fixture built from an earlier + // schema — so an absent column is nothing to fail over. + let has_crop = snapshot + .prepare("SELECT crop FROM faces LIMIT 1") + .map(|_| true) + .unwrap_or(false); + if !has_crop { + return Ok(()); + } + snapshot.execute("UPDATE faces SET crop = NULL WHERE crop IS NOT NULL", [])?; + snapshot.execute_batch("VACUUM")?; Ok(()) } @@ -250,4 +286,62 @@ mod tests { std::fs::create_dir_all(&base).unwrap(); base } + + /// The whole reason crops live in the shards: a snapshot is uploaded whole, + /// on every sync, to every device. + #[test] + fn the_snapshot_carries_no_face_crops() { + let dir = tempdir(); + let live = dir.join("catalog.sqlite"); + let snap = dir.join("snap.sqlite"); + + let c = seeded(&live); + c.execute( + "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (1, 1, 'a.CR3', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO faces + (image_id, x, y, w, h, landmarks, detector_confidence, embedding, + crop_px, model_id, detected_at, crop) + VALUES (1, 0.1, 0.1, 0.2, 0.2, X'00', 0.9, X'00', 180.0, 'm', 0, ?1)", + [vec![7u8; 4096]], + ) + .unwrap(); + + snapshot_for_upload(&c, &snap).unwrap(); + + let out = Connection::open(&snap).unwrap(); + let crops: i64 = out + .query_row( + "SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(crops, 0, "the snapshot still carries face crops"); + + // The face itself must still be there — only the pixels are dropped. + let faces: i64 = out + .query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0)) + .unwrap(); + assert_eq!(faces, 1); + + // And the local catalog keeps its crop: this strips the copy, never + // the original. + let kept: i64 = c + .query_row( + "SELECT COUNT(*) FROM faces WHERE crop IS NOT NULL", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(kept, 1, "stripping the snapshot damaged the live catalog"); + } }