//! TRACES: FR-CAT-7 | FR-CAT-5 | FR-NC-9 //! Merging a remote catalog's collections and keywords into the local one. //! //! # Why this is a merge and not a copy //! //! The catalog file syncs to Nextcloud, and a device that finds a newer remote //! copy must not simply replace its own — whichever device synced second would //! lose everything the first did not have. So the remote file is downloaded to //! a side path, `ATTACH`ed, and merged table by table. //! //! Row-level merging needs identities that are stable across devices, and //! `collections.id INTEGER PRIMARY KEY` is not: two devices independently //! allocate id 1 for different collections. Hence `collections.uuid`, which is //! what everything here keys on. The integer id stays local and is never //! compared across catalogs. //! //! # Conflict rule //! //! Per collection, by `revision` — a monotonic counter bumped on every local //! edit — with `modified` timestamp only as a tiebreak. Comparing revisions //! rather than mtimes means a device with a skewed clock cannot silently win //! (the failure mode FR-NC-9 avoids for sidecars, applied here). //! //! Membership merges as a **set union**, not last-writer-wins: two devices //! each adding different images to the same collection keep both sets. That //! is almost always what the user meant, and the exception — a removal racing //! an addition — resolves in favour of the addition, which is recoverable by //! removing it again. Silently losing an addition is not. //! //! # Deletion //! //! A deleted collection leaves a tombstone (`deleted = 1`), because a merge //! against a device that still holds it would otherwise resurrect it. The //! tombstone carries a revision like any other edit, so deletion competes on //! the same footing as a rename. //! //! # Keywords merge on the same three rules //! //! [`merge_keywords`] reuses all of the above rather than inventing a second //! set of rules, because a keyword is the same shape of problem as a //! collection: a named thing with a device-independent identity, and a //! many-to-many join to images. //! //! - The **vocabulary** (`keyword_terms`) is decided per row by [`verdict`], //! exactly as collections are. //! - The **assignments** (`keywords`) are a set union, exactly as membership //! is: two devices each keywording different photographs "puffin" keep both //! sets, and two devices each keywording the *same* photograph converge on //! one row rather than one of them winning. //! - **Deletion** tombstones, and takes the assignments with it. //! //! Two things are genuinely different, and both are consequences of assignments //! storing the *word* rather than a row id: //! //! 1. A tombstone deletes assignments **by name**, so a deletion still lands on //! a device that had minted its own identity for the same word. The union //! then refuses to readmit a word a winning tombstone has just removed — //! without that filter, the other device's live assignments would resurrect //! it on the very same pass. //! 2. Two devices that independently typed the same word arrive with two uuids //! for one keyword. [`crate::keywords::fuse_duplicates`] collapses them onto //! the lexicographically smaller one, which both devices compute identically. //! A unique index on the name would instead abort the merge transaction at //! that moment, which is the ordinary case rather than a corner one. use rusqlite::{Connection, OptionalExtension}; use crate::error::CatalogError; /// How a collection differed between the two catalogs. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MergeVerdict { /// Present only remotely — insert it. InsertedFromRemote, /// Remote revision is higher — take its fields. UpdatedFromRemote, /// Local revision is at least as high — keep ours. KeptLocal, /// Remote says deleted, and wins on revision. DeletedByRemote, } /// What a merge did, for logging and for telling the user. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct MergeReport { pub inserted: usize, pub updated: usize, pub kept_local: usize, pub deleted: usize, pub members_added: usize, // Keywords are counted separately from collections rather than summed into // the same fields. The report is shown to the user — "3 collections, 11 // keywords" is a sentence; "14 things" is not — and a merge that went wrong // is far easier to place when the counts say which half it went wrong in. /// Keywords the remote had and this device did not. pub keywords_inserted: usize, /// Keywords the remote had renamed, or brought back from a tombstone. pub keywords_updated: usize, /// Keywords the remote deleted, and this device has now deleted too. pub keywords_deleted: usize, /// Keywords where this device's revision was at least as high. pub keywords_kept_local: usize, /// People the remote had and this device did not. pub people_inserted: usize, /// People the remote had renamed, set aside, or brought back. pub people_updated: usize, /// People where this device's revision was at least as high. pub people_kept_local: usize, /// Faces this device now agrees belong to somebody. pub faces_assigned: usize, /// Faces whose local confirmation outranked the remote's. pub faces_kept_local: usize, /// "Not this person" judgements taken from the remote. pub faces_rejected: usize, /// Remote faces placed on a local one by their embedding, where the /// boxes disagreed or were ambiguous (see `match_faces`). pub faces_matched_by_embedding: usize, /// Assignments from the remote refused because this device already has /// that person on another face of the same photograph. pub faces_one_per_photograph: usize, /// Redundant identities for one word, retired by /// [`crate::keywords::fuse_duplicates`]. pub keywords_fused: usize, /// Keyword assignments taken from the remote. pub keywords_assigned: usize, /// Images whose capture metadata was taken from the remote. pub metadata_adopted: usize, /// Albums the remote had and this device did not, or had renamed, moved /// or deleted with the higher revision. pub albums_taken: usize, /// Albums where this device's revision was at least as high. pub albums_kept_local: usize, /// Exported files the remote had recorded into an album and this device /// had not. pub album_exports_added: usize, } impl MergeReport { /// Whether the local catalog changed, and so needs re-uploading. pub fn local_changed(&self) -> bool { self.inserted > 0 || self.updated > 0 || self.deleted > 0 || self.members_added > 0 || self.keywords_inserted > 0 || self.keywords_updated > 0 || self.keywords_deleted > 0 || self.keywords_fused > 0 || self.keywords_assigned > 0 || self.metadata_adopted > 0 || self.albums_taken > 0 || self.album_exports_added > 0 } /// Whether the local catalog holds anything the remote did not, and so /// must be uploaded even if nothing was taken from the remote. pub fn should_upload(&self) -> bool { self.kept_local > 0 || self.keywords_kept_local > 0 || self.albums_kept_local > 0 || self.local_changed() } } /// Decide one collection, given both sides' revisions. /// /// Split out from the SQL so the rule is testable on its own — it is the part /// that decides whether a user loses a collection. pub fn verdict( local: Option<(i64, i64)>, // (revision, modified) remote: (i64, i64), remote_deleted: bool, ) -> MergeVerdict { let (r_rev, r_mod) = remote; match local { None if remote_deleted => { // A tombstone for something we never had. Recording it still // matters: without it, a third device could reintroduce the // collection through us. MergeVerdict::DeletedByRemote } None => MergeVerdict::InsertedFromRemote, Some((l_rev, l_mod)) => { // Revision first; timestamp only to break an exact tie. Equal // revisions with equal timestamps keep local, so a merge that // changes nothing is stable and repeatable. let remote_wins = r_rev > l_rev || (r_rev == l_rev && r_mod > l_mod); if !remote_wins { MergeVerdict::KeptLocal } else if remote_deleted { MergeVerdict::DeletedByRemote } else { MergeVerdict::UpdatedFromRemote } } } } /// Merge everything that syncs, from an attached catalog. /// /// The remote catalog must already be attached under the schema name /// `remote_cat`; [`crate::sync::merge_remote`] handles that. /// /// **One transaction over both halves.** Keywords and collections are /// independent as data, but a merge that landed the collections and then failed /// on the keywords would leave a catalog that has already taken the remote's /// revisions for half of itself — and the next attempt, seeing those revisions, /// would decline to take them again. Half a merge is not a state that can be /// resumed, so it is not a state that can be reached. pub fn merge_all(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_collections_within(&tx, &mut report)?; merge_keywords_within(&tx, &mut report)?; merge_people_within(&tx, &mut report)?; merge_metadata_within(&tx, &mut report)?; merge_albums_within(&tx, &mut report)?; tx.commit()?; Ok(report) } /// Merge albums and what was exported into them, from an attached catalog. pub fn merge_albums(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_albums_within(&tx, &mut report)?; tx.commit()?; Ok(report) } /// The album half: rows by [`verdict`], exports as a set union. /// /// `album_folders` is not read. It is this device's choice of a local folder /// and means nothing on the device that sent the snapshot — which has in any /// case dropped it before uploading (see [`crate::albums`]). fn merge_albums_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // A snapshot from a build before albums, or from a device that never // made one, has no tables to read. Ours are made on demand so the // statements below have somewhere to write. if !remote_has(tx, "albums")? { return Ok(()); } crate::albums::ensure_tables(tx)?; /// One album as the remote has it, and what the verdict made of it. struct IncomingAlbum { uuid: String, name: String, server_path: Option, created: i64, revision: i64, modified: i64, deleted: bool, verdict: MergeVerdict, } let rows: Vec = { let mut stmt = tx.prepare( "SELECT r.uuid, r.name, r.server_path, r.created, r.revision, r.modified, r.deleted, l.revision, l.modified FROM remote_cat.albums r LEFT JOIN main.albums l ON l.uuid = r.uuid", )?; let rows = stmt .query_map([], |r| { let revision: i64 = r.get(4)?; let modified: i64 = r.get(5)?; let deleted: bool = r.get::<_, i64>(6)? != 0; let local_rev: Option = r.get(7)?; let local_mod: Option = r.get(8)?; Ok(IncomingAlbum { uuid: r.get(0)?, name: r.get(1)?, server_path: r.get(2)?, created: r.get(3)?, revision, modified, deleted, verdict: verdict(local_rev.zip(local_mod), (revision, modified), deleted), }) })? .collect::, _>>()?; rows }; { // One upsert covers insert, update and tombstone: the verdict has // already decided the remote row wins, so its fields are the answer // whichever of the three it is. let mut take = tx.prepare( "INSERT INTO main.albums (uuid, name, server_path, created, revision, modified, deleted) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(uuid) DO UPDATE SET name = excluded.name, server_path = excluded.server_path, revision = excluded.revision, modified = excluded.modified, deleted = excluded.deleted", )?; let mut forget = tx.prepare( "DELETE FROM main.album_exports WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)", )?; let mut unfold = tx.prepare( "DELETE FROM main.album_folders WHERE album_id = (SELECT id FROM main.albums WHERE uuid = ?1)", )?; for row in rows { if row.verdict == MergeVerdict::KeptLocal { report.albums_kept_local += 1; continue; } take.execute(rusqlite::params![ row.uuid, row.name, row.server_path, row.created, row.revision, row.modified, row.deleted as i64 ])?; if row.deleted { forget.execute([&row.uuid])?; unfold.execute([&row.uuid])?; } else if row.server_path.is_some() { // Another device moved the album to the server with the newer // revision; a local folder chosen here is no longer where it // goes. unfold.execute([&row.uuid])?; } report.albums_taken += 1; } } report.album_exports_added = tx.execute(ALBUM_EXPORTS_BY_FILE_ID, [])? + tx.execute(ALBUM_EXPORTS_BY_CONTENT_HASH, [])?; Ok(()) } /// Exported files, matched to local images by the server's file id — the /// identity [`MEMBERS_BY_FILE_ID`] explains. Tombstoned albums are excluded, /// or a merge would refill an album it had just deleted. const ALBUM_EXPORTS_BY_FILE_ID: &str = " INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at) SELECT la.id, re.file_name, li.id, re.exported_at FROM remote_cat.album_exports re JOIN remote_cat.albums ra ON ra.id = re.album_id JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0 JOIN remote_cat.remote rr ON rr.image_id = re.image_id JOIN main.remote lr ON lr.file_id = rr.file_id JOIN main.images li ON li.id = lr.image_id"; /// The same union by content hash, for a library with no server behind it. const ALBUM_EXPORTS_BY_CONTENT_HASH: &str = " INSERT OR IGNORE INTO main.album_exports(album_id, file_name, image_id, exported_at) SELECT la.id, re.file_name, li.id, re.exported_at FROM remote_cat.album_exports re JOIN remote_cat.albums ra ON ra.id = re.album_id JOIN main.albums la ON la.uuid = ra.uuid AND la.deleted = 0 JOIN remote_cat.images ri ON ri.id = re.image_id JOIN main.images li ON li.content_hash = ri.content_hash WHERE ri.content_hash IS NOT NULL"; /// Adopt capture metadata from an attached catalog, on its own. pub fn merge_metadata(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_metadata_within(&tx, &mut report)?; tx.commit()?; Ok(report) } /// Capture metadata a peer's sweep already read, for images this device has /// not dated yet. /// /// The `images` table is local state and the merge leaves it alone — except /// for these columns, which are not: a capture time, an offset, a camera, a /// lens and an ISO are facts about the file's bytes, identical on every /// device, and read by fetching a header per image across the whole library /// (`dr_ui::library::spawn_sweep`). A fresh device inherits its peers' /// thumbnails and faces from the shards and then spent hours re-reading /// every header for the timeline; the snapshot it had just merged held /// every one of those dates. /// /// Matched by `oc:fileid`, as collection membership is. Only rows still at /// `metadata_state < 2` take anything, and only from a remote row at 2: a /// date this device read for itself is never overwritten, and a peer that /// has not read one has nothing to give. The sweep's own query /// (`metadata_state < 2`) then finds nothing left to do for them. const METADATA_BY_FILE_ID: &str = " UPDATE main.images SET captured_at = r.captured_at, captured_offset = coalesce(main.images.captured_offset, r.captured_offset), camera = coalesce(main.images.camera, r.camera), lens = coalesce(main.images.lens, r.lens), iso = coalesce(main.images.iso, r.iso), metadata_state = 2 FROM (SELECT lr.image_id, ri.captured_at, ri.captured_offset, ri.camera, ri.lens, ri.iso FROM remote_cat.images ri JOIN remote_cat.remote rr ON rr.image_id = ri.id JOIN main.remote lr ON lr.file_id = rr.file_id WHERE ri.metadata_state >= 2 AND ri.captured_at IS NOT NULL) AS r WHERE main.images.id = r.image_id AND main.images.metadata_state < 2"; fn merge_metadata_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // A snapshot from before these columns, or from a library with no server // behind it, has nothing to join on. if !remote_has(tx, "remote")? || !remote_has_column(tx, "images", "metadata_state")? || !remote_has_column(tx, "images", "captured_offset")? { return Ok(()); } report.metadata_adopted = tx.execute(METADATA_BY_FILE_ID, [])?; Ok(()) } /// Merge people and identity judgements from an attached catalog. /// /// The people half of [`merge_all`], on its own, for the same reason the other /// two have one: the rules are independent and worth exercising alone. pub fn merge_people(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_people_within(&tx, &mut report)?; tx.commit()?; Ok(report) } /// Merge collections and membership from an attached catalog. /// /// The collections half of [`merge_all`], on its own. Kept as a public entry /// point because the two halves are genuinely independent, and because the /// rules for this one are worth being able to exercise without a keyword in /// sight. /// /// Runs in one transaction: a merge either lands whole or not at all. pub fn merge_collections(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_collections_within(&tx, &mut report)?; tx.commit()?; Ok(report) } /// Merge the keyword vocabulary and its assignments from an attached catalog. /// /// The keywords half of [`merge_all`], on its own. See the module header for /// the three rules and the two places keywords differ from collections. pub fn merge_keywords(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); merge_keywords_within(&tx, &mut report)?; tx.commit()?; Ok(report) } fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // ---- collections ------------------------------------------------------ { let mut stmt = tx.prepare( // The parent arrives as a *uuid*, not `r.parent_id`: row ids are // local to a catalog, so the remote's integer means nothing here. "SELECT r.uuid, r.name, rp.uuid, r.kind, r.selector_json, r.created, r.revision, r.modified, r.deleted, l.revision, l.modified FROM remote_cat.collections r LEFT JOIN main.collections l ON l.uuid = r.uuid LEFT JOIN remote_cat.collections rp ON rp.id = r.parent_id", )?; struct Incoming { uuid: String, name: String, kind: i64, /// The parent's uuid, resolved to a local row id once every /// incoming collection exists — a child can arrive before its /// parent, so this cannot be applied inline. parent_uuid: Option, selector_json: Option, created: i64, revision: i64, modified: i64, // No `deleted` field: the verdict already encodes it, and keeping // both invites the two disagreeing. verdict: MergeVerdict, } let rows: Vec = stmt .query_map([], |r| { let deleted: i64 = r.get(8)?; let local_rev: Option = r.get(9)?; let local_mod: Option = r.get(10)?; let revision: i64 = r.get(6)?; let modified: i64 = r.get(7)?; Ok(Incoming { uuid: r.get(0)?, name: r.get(1)?, kind: r.get(3)?, parent_uuid: r.get(2)?, selector_json: r.get(4)?, created: r.get(5)?, revision, modified, verdict: verdict(local_rev.zip(local_mod), (revision, modified), deleted != 0), }) })? .collect::>()?; // Parentage is applied after the loop: a child can arrive before its // parent, so resolving the uuid inline would find nothing and silently // flatten the tree. let mut reparent: Vec<(String, Option)> = Vec::new(); for row in rows { match row.verdict { MergeVerdict::KeptLocal => { report.kept_local += 1; } MergeVerdict::InsertedFromRemote => { tx.execute( "INSERT INTO main.collections (uuid, name, parent_id, kind, selector_json, created, revision, modified, deleted) VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, ?7, 0)", rusqlite::params![ row.uuid, row.name, row.kind, row.selector_json, row.created, row.revision, row.modified, ], )?; reparent.push((row.uuid.clone(), row.parent_uuid.clone())); report.inserted += 1; } MergeVerdict::UpdatedFromRemote => { tx.execute( "UPDATE main.collections SET name = ?2, kind = ?3, selector_json = ?4, revision = ?5, modified = ?6, deleted = 0 WHERE uuid = ?1", rusqlite::params![ row.uuid, row.name, row.kind, row.selector_json, row.revision, row.modified, ], )?; reparent.push((row.uuid.clone(), row.parent_uuid.clone())); report.updated += 1; } MergeVerdict::DeletedByRemote => { // Tombstone rather than DELETE: the row must outlive the // deletion or a third device reintroduces it. tx.execute( "INSERT INTO main.collections (uuid, name, kind, created, revision, modified, deleted) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1) ON CONFLICT(uuid) DO UPDATE SET deleted = 1, revision = ?5, modified = ?6", rusqlite::params![ row.uuid, row.name, row.kind, row.created, row.revision, row.modified, ], )?; tx.execute( "DELETE FROM main.collection_members WHERE collection_id = (SELECT id FROM main.collections WHERE uuid = ?1)", [&row.uuid], )?; report.deleted += 1; } } } // Second pass: every incoming collection now exists locally, so a // parent uuid can be resolved to a row id. A parent we have never seen // resolves to NULL, which leaves the collection at the top level — // wrong, but visible and recoverable, where a dangling id would not be. // // Without this the tree flattened on every sync: `parent_id` is a local // row id and was written as NULL rather than translated, so a nested // collection came back from a round trip at the top level. for (uuid, parent_uuid) in reparent { // Remote's tree is acyclic and so is ours, but the union of the two // need not be: if we hold A above B and the remote holds B above A, // applying only the winning half closes a loop, and `descendants` // would then spin. Walk up from the proposed parent first; reaching // the collection itself means this edge would close a cycle, so the // safe move is to leave it where it is. if let Some(ref parent) = parent_uuid { let closes_cycle: bool = tx.query_row( "WITH RECURSIVE up(id) AS ( SELECT id FROM main.collections WHERE uuid = ?2 UNION SELECT c.parent_id FROM main.collections c JOIN up ON c.id = up.id WHERE c.parent_id IS NOT NULL ) SELECT EXISTS( SELECT 1 FROM up WHERE id = (SELECT id FROM main.collections WHERE uuid = ?1))", rusqlite::params![uuid, parent], |r| r.get(0), )?; if closes_cycle { continue; } } tx.execute( "UPDATE main.collections SET parent_id = (SELECT id FROM main.collections WHERE uuid = ?2) WHERE uuid = ?1", rusqlite::params![uuid, parent_uuid], )?; } } // ---- membership ------------------------------------------------------- // // Set union, keyed on (collection uuid, image identity). Not the image id, // for the same reason collections use a uuid: image ids are local. An image // the remote has and we do not is skipped — it will join when a scan or // sync catalogues it, and the next merge picks it up. // // Both identities are tried, and the file id first. See // [`MEMBERS_BY_FILE_ID`] for why keying on the content hash alone made this // whole union a no-op on every ordinary library. // // Tombstoned collections are excluded, or a merge would repopulate a // collection it had just deleted. let added = tx.execute(MEMBERS_BY_FILE_ID, [])? + tx.execute(MEMBERS_BY_CONTENT_HASH, [])?; report.members_added = added; Ok(()) } /// Take membership for images both devices know by the server's file id. /// /// **This is the identity that exists.** Membership was keyed on /// `images.content_hash` alone, and the schema is explicit that the column is /// "computed only when something needs it (import dedup, reconnect-by-hash), /// never in a scan" — so on an ordinary library it is NULL for every row, the /// join matched nothing, and `WHERE ri.content_hash IS NOT NULL` discarded what /// little was left. Collections synced their names, because those are keyed on /// a uuid, and arrived empty on every device. A 23,000-image library had a /// content hash for none of them and an `oc:fileid` for all of them. /// /// `oc:fileid` is recorded for every image the moment a remote scan sees it, is /// stable across server-side rename and move (FR-NC-5), and is the same integer /// on every device pointed at the same Nextcloud — which is exactly the /// situation where two devices share collections. It is already what the /// thumbnail shards are keyed by, and what [`ASSIGN_BY_FILE_ID`] uses for /// keywords; membership was the one thing left behind. const MEMBERS_BY_FILE_ID: &str = " INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added) SELECT lc.id, li.id, rm.position, rm.added FROM remote_cat.collection_members rm JOIN remote_cat.collections rc ON rc.id = rm.collection_id JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0 JOIN remote_cat.remote rr ON rr.image_id = rm.image_id JOIN main.remote lr ON lr.file_id = rr.file_id JOIN main.images li ON li.id = lr.image_id"; /// The same union for a library with no server behind it. /// /// A local-only library has no `remote` rows at all, so [`MEMBERS_BY_FILE_ID`] /// matches nothing and the content hash is the only identity available. Kept /// rather than replaced: where a hash *has* been computed — an imported card, /// a reconnect — it is a true identity, and one that survives a library moving /// between servers. /// /// Both statements run. `INSERT OR IGNORE` against the /// `(collection_id, image_id)` primary key makes the overlap free. const MEMBERS_BY_CONTENT_HASH: &str = " INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added) SELECT lc.id, li.id, rm.position, rm.added FROM remote_cat.collection_members rm JOIN remote_cat.collections rc ON rc.id = rm.collection_id JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0 JOIN remote_cat.images ri ON ri.id = rm.image_id JOIN main.images li ON li.content_hash = ri.content_hash WHERE ri.content_hash IS NOT NULL"; /// Schema name the downloaded remote catalog is attached under. /// /// Repeated from [`crate::sync`] rather than shared, because the SQL below /// spells it inline and a constant that only half the file used would be worse /// than no constant at all. const REMOTE: &str = "remote_cat"; /// The keyword half. See the module header. fn merge_keywords_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // ---- the vocabulary --------------------------------------------------- // // A remote written before schema v6 has no `keyword_terms` at all, and // `remote_is_mergeable` deliberately admits it: the check is that the // remote is not *newer* than us. So the table's absence is a normal state // and not an error. Its assignments still merge below — those have been in // the schema since v1 — and its words gain identities on that device the // next time it opens the catalog and backfills. if attached_has_table(tx, REMOTE, "keyword_terms")? { struct Incoming { uuid: String, name: String, /// What this device currently calls the same identity, if it has /// it. A rename is applied to the assignment rows by rewriting this /// text, so it has to be read before the term row is overwritten. local_name: Option, created: i64, revision: i64, modified: i64, verdict: MergeVerdict, } let rows: Vec = { let mut stmt = tx.prepare( "SELECT r.uuid, r.name, r.created, r.revision, r.modified, r.deleted, l.name, l.revision, l.modified FROM remote_cat.keyword_terms r LEFT JOIN main.keyword_terms l ON l.uuid = r.uuid", )?; let found = stmt .query_map([], |r| { let deleted: i64 = r.get(5)?; let local_rev: Option = r.get(7)?; let local_mod: Option = r.get(8)?; let revision: i64 = r.get(3)?; let modified: i64 = r.get(4)?; Ok(Incoming { uuid: r.get(0)?, name: r.get(1)?, local_name: r.get(6)?, created: r.get(2)?, revision, modified, verdict: verdict( local_rev.zip(local_mod), (revision, modified), deleted != 0, ), }) })? .collect::, _>>()?; found }; for row in rows { match row.verdict { MergeVerdict::KeptLocal => { report.keywords_kept_local += 1; } MergeVerdict::InsertedFromRemote => { tx.execute( "INSERT INTO main.keyword_terms (uuid, name, created, revision, modified, deleted) VALUES (?1, ?2, ?3, ?4, ?5, 0)", rusqlite::params![ row.uuid, row.name, row.created, row.revision, row.modified, ], )?; report.keywords_inserted += 1; } MergeVerdict::UpdatedFromRemote => { // The assignments carry the *word*, so taking a new name // for an identity we already hold means rewriting every row // spelt the old way. Without this the vocabulary would show // the new spelling and the search would only find the old. if let Some(old) = row.local_name.filter(|n| *n != row.name) { tx.execute( "INSERT OR IGNORE INTO main.keywords(version_id, keyword) SELECT version_id, ?2 FROM main.keywords WHERE keyword = ?1", rusqlite::params![old, row.name], )?; tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&old])?; } tx.execute( "UPDATE main.keyword_terms SET name = ?2, revision = ?3, modified = ?4, deleted = 0 WHERE uuid = ?1", rusqlite::params![row.uuid, row.name, row.revision, row.modified], )?; report.keywords_updated += 1; } MergeVerdict::DeletedByRemote => { // Tombstone rather than DELETE, or a third device // reintroduces the keyword through us. tx.execute( "INSERT INTO main.keyword_terms (uuid, name, created, revision, modified, deleted) VALUES (?1, ?2, ?3, ?4, ?5, 1) ON CONFLICT(uuid) DO UPDATE SET deleted = 1, revision = ?4, modified = ?5", rusqlite::params![ row.uuid, row.name, row.created, row.revision, row.modified, ], )?; // **By name, not by identity.** This device may well have // minted its own uuid for the same word before the two ever // synced, in which case deleting by uuid would tombstone a // row that nothing is assigned to and leave every // photograph still carrying the word. tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&row.name])?; report.keywords_deleted += 1; } } } // Two devices that each typed "Iceland" now hold two identities for one // word. Collapse them before the assignments arrive, so the vocabulary // the user sees after a sync has one row per word. report.keywords_fused = crate::keywords::fuse_duplicates(tx)?; } // ---- assignments ------------------------------------------------------ // // Set union, and the union is the whole point: FR-NC-9's principle applied // to metadata rather than to edit nodes. Two devices that keyworded // different frames "puffin" both keep their work, and neither loses it to // whichever synced second. // // A removal therefore does not propagate — the remote's assignment simply // reappears. That is the same trade-off collection membership makes above, // and for the same reason: an unwanted keyword is removed again in a // second, and a silently lost afternoon of keywording is not recoverable at // all. Making removal propagate needs a tombstone per assignment, which is // a schema change and a merge rule of its own. // // An incoming keyword lands on the local default version, so an image that // has not got one yet would silently drop it. That is not a rare state: the // invariant is maintained by a backfill on open, and a scan that ran since // has added rows it has not covered. Losing a word the user typed on // another device, for a bookkeeping reason, would be the wrong answer — // this is idempotent and writes nothing once the invariant holds. crate::rating::ensure_default_versions_within(tx)?; // Two passes rather than one statement with an `OR`, because they resolve // *different identities* for the same photograph and each wants its own // index. See [`ASSIGN_BY_FILE_ID`] for why there are two at all. for sql in [ASSIGN_BY_FILE_ID, ASSIGN_BY_CONTENT_HASH] { report.keywords_assigned += tx.execute(sql, [])?; } Ok(()) } /// Take assignments for images both devices know by the server's file id. /// /// **Preferred over the content hash**, and the reason is that `content_hash` /// is expensive — the schema says so, and it is computed only when import /// dedup or a reconnect asks for it, which for most libraries is never. Keying /// keywords on it alone would mean the union quietly did nothing for the /// ordinary image, which is the exact failure this merge exists to prevent. /// /// `oc:fileid` is the opposite: it is recorded for every image the moment a /// remote scan sees it, it is stable across server-side renames and moves, and /// it is the same integer on every device pointed at the same Nextcloud — which /// is precisely the situation where two devices are keywording one library. /// /// The keyword lands on the local image's **default version**, not on the /// version it came from. Version uuids do not reconcile across devices in the /// catalog: [`crate::rating::ensure_default_versions`] mints a fresh one per /// device, so the same photograph's default versions have different uuids on /// two machines and a uuid-keyed join would union nothing at all. Version /// identity is reconciled in the *sidecar* (FR-NC-8), and until a merged /// version arrives through there, the default version is both where /// [`crate::keywords::assign`] writes and where the panel reads — so it is the /// one place the word can land and be seen. /// /// A word is refused when this device holds it only as a tombstone: deleted /// under some identity and live under none. That is a set of words, the same /// for every row, so it is asked once -- a list SQLite builds before the walk /// -- rather than as a correlated `NOT EXISTS ... OR EXISTS` per incoming /// assignment. The `deleted = 1` half of that could use no index /// (`keyword_terms_name` holds only live rows) and scanned the whole /// vocabulary for each of 10,800 assignments: 70 ms of a sync pass that /// changed nothing, on the reference library, against 11 ms now. const ASSIGN_BY_FILE_ID: &str = " INSERT OR IGNORE INTO main.keywords(version_id, keyword) SELECT lv.id, rk.keyword FROM remote_cat.keywords rk JOIN remote_cat.versions rv ON rv.id = rk.version_id JOIN remote_cat.remote rr ON rr.image_id = rv.image_id JOIN main.remote lr ON lr.file_id = rr.file_id JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1 WHERE rk.keyword NOT IN (SELECT name FROM main.keyword_terms WHERE deleted = 1 EXCEPT SELECT name FROM main.keyword_terms WHERE deleted = 0)"; /// The same union for a library with no server behind it. /// /// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`] /// matches nothing and this is the only identity available — the same pair /// collection membership uses, so a library where membership merges has /// keywords that merge too. const ASSIGN_BY_CONTENT_HASH: &str = " INSERT OR IGNORE INTO main.keywords(version_id, keyword) SELECT lv.id, rk.keyword FROM remote_cat.keywords rk JOIN remote_cat.versions rv ON rv.id = rk.version_id JOIN remote_cat.images ri ON ri.id = rv.image_id JOIN main.images li ON li.content_hash = ri.content_hash JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1 WHERE ri.content_hash IS NOT NULL AND rk.keyword NOT IN (SELECT name FROM main.keyword_terms WHERE deleted = 1 EXCEPT SELECT name FROM main.keyword_terms WHERE deleted = 0)"; /// Whether an attached database holds a table of this name. /// /// The schema name and the table name are both literals from this file, never /// user text — but they are still bound rather than formatted where SQLite /// allows it, because the habit is what keeps the one that eventually is user /// text from being formatted by accident. fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result { let sql = format!("SELECT count(*) FROM {schema}.sqlite_master WHERE type = 'table' AND name = ?1"); let n: i64 = conn.query_row(&sql, [table], |r| r.get(0))?; Ok(n > 0) } // ── people, and who the user said they are ──────────────────────────────── /// Merge people and the user's identity judgements from an attached catalog. /// /// # Why this is here at all /// /// Face *data* syncs as sealed shards ([`crate::face_shard`]) — boxes, /// landmarks, embeddings, the run marker. What the shards deliberately do not /// carry is who anybody **is**: the person rows, their names, and the /// assignments joining the two. Those were supposed to travel in the catalog /// snapshot, which is a whole-file copy and therefore does contain them — but /// the snapshot is *merged*, not adopted, and this merge only ever looked at /// collections and keywords. So a second device received every face and no /// people at all, and drew an empty People screen over a full catalog. /// /// # What travels, and what is recomputed /// /// The rule this module already follows for the rest of the catalog: user /// judgements travel, inference is rebuilt. Concretely (docs/dev/faces.md, and the /// asymmetry `crate::faces` opens with): /// /// - **People** — uuid, name, and whether the user set them aside. Merged by /// uuid on `revision`, exactly as a collection is. /// - **Confirmations** — the user said this face is this person. /// - **Rejections** — the user said it is *not*, which is equally a fact and /// is why re-clustering does not put it back. /// - **The assignments inside an ignored group** — carried even though they are /// only suggestions, because they are what anchors the ignore. Without them /// a group set aside on one device reappears on the other, which is the same /// fault that made "Not interested" not stick locally. /// /// Ordinary suggestions are *not* carried. They are this pass's own output, /// clustering is deterministic, and both devices hold the same embeddings — so /// each recomputes them and arrives at the same answer. Shipping them would /// double the merge for no new information. /// /// # Faces have no cross-device identity, so one is derived /// /// `faces.id` is a local row id and means nothing in another catalog; there is /// no uuid to fall back on. What both devices *do* agree on is `oc:fileid`, /// the box, and the embedding, so a remote face is matched to the local face /// on the same photograph whose box overlaps it, above a floor of 0.5 IoU — /// or, where no box or two boxes do, whose vector it decisively resembles /// ([`match_faces`]). /// /// That is not a new rule: it is the one /// [`crate::faces::record_detections`] already uses to carry a confirmation /// across a re-index, and it is loose on purpose — the question is "is this the /// same face in the frame", not "is this the same rectangle", and a device /// running a newer detector is entitled to have moved the box. fn merge_people_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // A remote written before faces existed has none of these tables, and one // written before V10 has no `ignored`. Both are ordinary — `remote_is_ // mergeable` admits any catalog at or below this schema version — so they // are probed for rather than assumed, and an absent one skips this half // instead of aborting a merge that would otherwise have succeeded. if !remote_has(tx, "people")? || !remote_has(tx, "faces")? { return Ok(()); } let ignored_col = remote_has_column(tx, "people", "ignored")?; // Spelled into the SQL rather than branched around it: the two queries // below would otherwise each need a second copy. let ignored_sel = if ignored_col { "r.ignored" } else { "0" }; // ---- the people themselves ------------------------------------------- struct Incoming { uuid: String, name: String, ignored: bool, created: i64, revision: i64, modified: i64, /// Resolved after every person exists, since a merge target can arrive /// after the person redirecting to it. merged_into_uuid: Option, verdict: MergeVerdict, } let rows: Vec = { let mut stmt = tx.prepare(&format!( "SELECT r.uuid, r.name, {ignored_sel}, r.created, r.revision, r.modified, rm.uuid, l.revision, l.modified FROM remote_cat.people r LEFT JOIN main.people l ON l.uuid = r.uuid LEFT JOIN remote_cat.people rm ON rm.id = r.merged_into" ))?; let mapped = stmt.query_map([], |r| { let revision: i64 = r.get(4)?; let modified: i64 = r.get(5)?; let local_rev: Option = r.get(7)?; let local_mod: Option = r.get(8)?; Ok(Incoming { uuid: r.get(0)?, name: r.get(1)?, ignored: r.get(2)?, created: r.get(3)?, revision, modified, merged_into_uuid: r.get(6)?, // People have no tombstone: a person is merged away rather // than deleted, and `merged_into` is that redirect. verdict: verdict(local_rev.zip(local_mod), (revision, modified), false), }) })?; mapped.collect::>()? }; for p in &rows { match p.verdict { MergeVerdict::InsertedFromRemote => { tx.execute( "INSERT INTO people (uuid, name, ignored, created, revision, modified) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![p.uuid, p.name, p.ignored, p.created, p.revision, p.modified], )?; report.people_inserted += 1; } MergeVerdict::UpdatedFromRemote | MergeVerdict::DeletedByRemote => { tx.execute( "UPDATE people SET name = ?2, ignored = ?3, revision = ?4, modified = ?5 WHERE uuid = ?1", rusqlite::params![p.uuid, p.name, p.ignored, p.revision, p.modified], )?; report.people_updated += 1; } MergeVerdict::KeptLocal => report.people_kept_local += 1, } } // Redirects, once every person on both sides exists locally. for p in rows.iter().filter(|p| p.merged_into_uuid.is_some()) { if p.verdict == MergeVerdict::KeptLocal { continue; } tx.execute( "UPDATE people SET merged_into = (SELECT id FROM people WHERE uuid = ?2) WHERE uuid = ?1", rusqlite::params![p.uuid, p.merged_into_uuid], )?; } // ---- match the remote's faces onto this device's ---------------------- let matched = match_faces(tx)?; report.faces_matched_by_embedding += matched.by_embedding; let FaceMatch { map: face_map, on_file, file_of, .. } = matched; if face_map.is_empty() { return Ok(()); } // ---- confirmations, and the anchors under an ignored group ----------- { // The person is resolved in the same statement, by the local // `people.uuid` key, and what this device already holds is read once // for the whole pass and looked up in memory. A steady-state pass // walks every confirmed and every ignored face the other device // holds -- 13,000 on the reference library -- and three statements // per face, even cached, were 52 ms of it. In the key's order, which // is the order the table is walked in anyway: when two of its faces // match one of ours, which one is applied last decides the answer. let mut stmt = tx.prepare(&format!( "SELECT fp.face_id, lp.id, fp.probability, fp.confirmed FROM remote_cat.face_person fp JOIN remote_cat.people p ON p.id = fp.person_id JOIN main.people lp ON lp.uuid = p.uuid WHERE fp.confirmed = 1 OR {} = 1 ORDER BY fp.face_id", if ignored_col { "p.ignored" } else { "0" } ))?; let incoming: Vec<(i64, i64, f64, bool)> = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? .collect::>()?; // Kept current as the loop writes: two of the other device's faces // can match one of ours, and the second must see what the first left. let mut held: std::collections::HashMap = tx .prepare("SELECT face_id, person_id, probability, confirmed FROM main.face_person")? .query_map([], |r| Ok((r.get(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))))? .collect::>()?; let rejected: std::collections::HashSet<(i64, i64)> = tx .prepare("SELECT face_id, person_id FROM main.face_person_rejected")? .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect::>()?; let mut assign = tx.prepare_cached( "INSERT INTO face_person (face_id, person_id, probability, confirmed) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(face_id) DO UPDATE SET person_id = excluded.person_id, probability = excluded.probability, confirmed = excluded.confirmed", )?; let mut withdraw = tx.prepare_cached("DELETE FROM face_person WHERE face_id = ?1 AND confirmed = 0")?; for (remote_face, person, probability, confirmed) in incoming { let Some(&local_face) = face_map.get(&remote_face) else { continue; }; let current = held.get(&local_face).copied(); // A local confirmation is never overwritten, in either direction. // Two devices confirming the same face as different people is a // genuine disagreement and there is no revision on an assignment to // settle it with; silently taking the remote's answer would let a // sync undo something the user did here. It stays as it is, and the // user can change it on the device they are looking at. if current.is_some_and(|(_, _, confirmed)| confirmed == 1) { report.faces_kept_local += 1; continue; } // A rejection here outranks an assignment from elsewhere: it is // this user's judgement about this pair, and re-suggesting what // they pushed away is the behaviour that makes the feature feel // broken. if rejected.contains(&(local_face, person)) { continue; } // One person, one face per photograph -- the cannot-link the // grouping pass already keeps (`dr_face::cluster`), which the // merge did not. When the two devices disagree about *which* // face in a frame is somebody, taking the remote's answer // beside this device's own puts the person on both. The // reference library holds 80 such pairs (79 set-aside // strangers, one named person), the same on both devices. The // face this device already gave the person keeps them, unless // the remote's is a confirmation and this device's only a // suggestion. // // A face that already holds the person adds nothing beside it, // whatever else the photograph holds. let adds_person = current.is_none_or(|(held_person, ..)| held_person != person); let rival = file_of .get(&local_face) .filter(|_| adds_person) .and_then(|file| { on_file[file].iter().copied().find(|&other| { other != local_face && held.get(&other).is_some_and(|h| h.0 == person) }) }); if let Some(rival) = rival { if !confirmed || held[&rival].2 == 1 { report.faces_one_per_photograph += 1; continue; } withdraw.execute([rival])?; held.remove(&rival); } // Written only when it differs. Rewriting a row with the values it // already holds dirtied a page per face, every pass, for nothing; // the report still counts it, as it always has. let wanted = (person, probability, i64::from(confirmed)); if current != Some(wanted) { assign.execute(rusqlite::params![ local_face, person, probability, confirmed ])?; held.insert(local_face, wanted); } report.faces_assigned += 1; } } // ---- rejections, as a set union -------------------------------------- { let mut stmt = tx.prepare( "SELECT fr.face_id, p.uuid FROM remote_cat.face_person_rejected fr JOIN remote_cat.people p ON p.id = fr.person_id", )?; let incoming: Vec<(i64, String)> = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? .collect::>()?; let mut person_of = tx.prepare_cached("SELECT id FROM people WHERE uuid = ?1")?; let mut reject = tx.prepare_cached( "INSERT OR IGNORE INTO face_person_rejected (face_id, person_id) VALUES (?1, ?2)", )?; let mut unsuggest = tx.prepare_cached( "DELETE FROM face_person WHERE face_id = ?1 AND person_id = ?2 AND confirmed = 0", )?; for (remote_face, uuid) in incoming { let Some(&local_face) = face_map.get(&remote_face) else { continue; }; let person: Option = person_of.query_row([&uuid], |r| r.get(0)).optional()?; let Some(person) = person else { continue }; let n = reject.execute([local_face, person])?; report.faces_rejected += n; // A rejection that lands on a face currently *suggested* to be // that person has to take the suggestion with it, or the screen // keeps offering exactly what the other device just refused. unsuggest.execute([local_face, person])?; } } Ok(()) } /// Whether the attached remote holds a table. fn remote_has(tx: &Connection, table: &str) -> Result { Ok(tx.query_row( "SELECT EXISTS(SELECT 1 FROM remote_cat.sqlite_master WHERE type = 'table' AND name = ?1)", [table], |r| r.get::<_, bool>(0), )?) } /// Whether a table in the attached remote holds a column. fn remote_has_column(tx: &Connection, table: &str, column: &str) -> Result { // `pragma_table_info` takes the schema as a second argument, which is the // only way to ask about an attached database rather than the main one. let mut stmt = tx.prepare("SELECT 1 FROM pragma_table_info(?1, 'remote_cat') WHERE name = ?2")?; Ok(stmt.exists(rusqlite::params![table, column])?) } /// The cosine above which two vectors from one embedder, on one /// photograph, on two devices, are taken to be the same face when the boxes /// do not say so. /// /// Measured on the reference library against the tablet's snapshot /// (2026-09-26, #77), both `w600k_mbf`: of the 169,548 pairs of *different* /// faces in one photograph, four reach 0.7 and none 0.83 -- lookalikes in /// one frame, a parent and child. Of the 18,348 pairs the boxes match, 94% /// are above 0.9; the tail below is one face cut by two detectors, which is /// why this never overrules a box that matches on its own. At 0.6 two of /// the pairs it would claim carry different people on the two devices; at /// 0.7 none of the twenty it claims does, and ten carry the same person on /// both. Stricter than [`crate::faces::SAME_FACE_COSINE`] because that one /// is only asked about boxes that overlap, and this one is asked about boxes /// that do not. const SAME_FACE_ACROSS_DEVICES: f32 = 0.7; /// How far a face's best counterpart must lead its second best, on both /// sides, for the embedding to decide. A face that two others resemble /// almost equally is exactly the one a merge must not guess at; every pair /// the rule claims on the reference library leads by more than 0.5. const DECISIVE_MARGIN: f32 = 0.2; /// What [`match_faces`] found. #[derive(Default)] struct FaceMatch { /// Remote face row id to local face row id; one-to-one. map: std::collections::HashMap, /// Every local face on a synced photograph, by the photograph's /// cross-device id -- what "another face in the same photograph" means /// to the merge. on_file: std::collections::HashMap>, /// The inverse of `on_file`. file_of: std::collections::HashMap, /// Pairs the boxes could not settle and the embeddings did. by_embedding: usize, } /// Remote face row id to local face row id, by photograph, box and vector. /// /// See [`merge_people_within`] for why a face has no shared identity and this /// has to be derived. Faces are compared within an *embedder* /// (`faces::embedder_of`), not within an exact pipeline id: two detectors in /// front of the same embedder draw boxes around the same faces, and a /// confirmation made on one device's box is about the face, not the /// rectangle — the same judgement `faces::record_detections` makes when it /// carries a confirmation across a re-detection. Keying on the exact id was /// what let a detector change strand every name on the device that made it. /// /// # Two passes, and the second is rare /// /// **By box.** A remote face and a local one on the same photograph are the /// same face when their boxes overlap by at least 0.5 IoU and neither has /// another such candidate. That settles 18,348 of the reference library's /// 19,052 remote faces, reads no vector, and is the whole of a steady-state /// pass. /// /// **By embedding**, only on the photographs where a remote face is left /// over -- no box overlapped it, or two did. Their vectors are read (a few /// hundred photographs, not the library's 19 MB of them) and a remote face is /// paired with the local face it resembles most when the cosine is at least /// [`SAME_FACE_ACROSS_DEVICES`], the pair is each other's best, each leads /// its runner-up by [`DECISIVE_MARGIN`], and the local face was not already /// claimed by a box. That is the face whose box one device drew somewhere /// else -- twenty on the reference library, boxes at IoU 0 with cosines of /// 0.72 to 0.96 -- and the face between two overlapping boxes. Anything less /// decisive stays unmatched, which is what a new face is: a name that fails /// to cross can be given again, a name put on the wrong face is a false /// merge the user has to find. fn match_faces(tx: &Connection) -> Result { use std::collections::HashMap; /// Loose on purpose — "the same face in the frame", not "the same /// rectangle". The figure `record_detections` uses for the same job. const MIN_IOU: f32 = 0.5; type Boxed = (i64, f32, f32, f32, f32); type Key = (i64, String); ensure_face_box_index(tx); let read_boxes = |sql: &str| -> Result>, CatalogError> { let mut out: HashMap> = HashMap::new(); let mut stmt = tx.prepare(sql)?; let rows = stmt.query_map([], |r| { Ok(( r.get::<_, i64>(1)?, r.get::<_, String>(2)?, ( r.get::<_, i64>(0)?, r.get::<_, f64>(3)? as f32, r.get::<_, f64>(4)? as f32, r.get::<_, f64>(5)? as f32, r.get::<_, f64>(6)? as f32, ), )) })?; for row in rows { let (file_id, model, boxed) = row?; let embedder = crate::faces::embedder_of(&model).to_string(); out.entry((file_id, embedder)).or_default().push(boxed); } Ok(out) }; // Local faces, grouped by the photograph's cross-device id. let local = read_boxes( "SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h FROM main.faces f JOIN main.remote r ON r.image_id = f.image_id WHERE r.file_id IS NOT NULL", )?; if local.is_empty() { return Ok(FaceMatch::default()); } let mut out = FaceMatch::default(); for ((file_id, _), faces) in &local { let on = out.on_file.entry(*file_id).or_default(); for &(id, ..) in faces { on.push(id); out.file_of.insert(id, *file_id); } } let remote = read_boxes( "SELECT f.id, r.file_id, f.model_id, f.x, f.y, f.w, f.h FROM remote_cat.faces f JOIN remote_cat.remote r ON r.image_id = f.image_id WHERE r.file_id IS NOT NULL", )?; // ---- by box ----------------------------------------------------------- // Per group, which local face (by index) each remote face took. let mut left_over: Vec<(&Key, Vec>)> = Vec::new(); for (key, theirs) in &remote { let Some(ours) = local.get(key) else { continue; }; let overlaps: Vec> = theirs .iter() .map(|&(_, x, y, w, h)| { ours.iter() .map(|&(_, lx, ly, lw, lh)| iou((x, y, w, h), (lx, ly, lw, lh)) >= MIN_IOU) .collect() }) .collect(); let mut taken: Vec> = vec![None; theirs.len()]; for (i, row) in overlaps.iter().enumerate() { let mut hits = row.iter().enumerate().filter(|(_, &hit)| hit); let (Some((j, _)), None) = (hits.next(), hits.next()) else { continue; }; if overlaps.iter().filter(|other| other[j]).count() == 1 { taken[i] = Some(j); out.map.insert(theirs[i].0, ours[j].0); } } // Worth reading vectors for only where a remote face is still // unplaced and a local face is still free to be its counterpart. let free = ours.len() > taken.iter().flatten().count(); if free && taken.iter().any(Option::is_none) { left_over.push((key, taken)); } } if left_over.is_empty() { return Ok(out); } // ---- by embedding, for what the boxes left -------------------------- let wanted = |side: &HashMap>| -> String { let ids: Vec = left_over .iter() .flat_map(|(key, _)| side[*key].iter().map(|b| b.0.to_string())) .collect(); format!("[{}]", ids.join(",")) }; // One statement per side, keyed by row id, for the faces of those // photographs only. let read_vectors = |schema: &str, ids: String| -> Result>, CatalogError> { let mut stmt = tx.prepare(&format!( "SELECT f.id, f.embedding FROM json_each(?1) j JOIN {schema}.faces f ON f.id = j.value" ))?; let rows = stmt.query_map([ids], |r| Ok((r.get(0)?, r.get(1)?)))?; Ok(rows.collect::>()?) }; let our_vectors = read_vectors("main", wanted(&local))?; let their_vectors = read_vectors("remote_cat", wanted(&remote))?; for (key, taken) in left_over { let model = dr_face::ModelId::new(key.1.as_str()); let decode = |vectors: &HashMap>, faces: &[Boxed]| -> Vec> { faces .iter() .map(|b| { let blob = vectors.get(&b.0)?; dr_face::Embedding::from_f16_bytes(model.clone(), blob) }) .collect() }; let (theirs, ours) = (&remote[key], &local[key]); let pairs = pair_by_embedding( &decode(&their_vectors, theirs), &decode(&our_vectors, ours), &taken, ); for (i, j) in pairs { out.map.insert(theirs[i].0, ours[j].0); out.by_embedding += 1; } } Ok(out) } /// The pairs the embeddings decide, as `(remote index, local index)`, for the /// remote faces the boxes left unplaced (`taken[i] == None`). /// /// Both sides are compared in full -- a local face a box already claimed can /// still be a remote face's best resemblance, and then that remote face is /// not placed elsewhere, because its best counterpart is spoken for and its /// second best is not decisive. Vectors that are missing or of another /// embedder compare as nothing ([`dr_face::Embedding::cosine`]). fn pair_by_embedding( theirs: &[Option], ours: &[Option], taken: &[Option], ) -> Vec<(usize, usize)> { let cos: Vec> = theirs .iter() .map(|t| { ours.iter() .map(|o| match (t, o) { (Some(t), Some(o)) => t.cosine(o).unwrap_or(f32::NEG_INFINITY), _ => f32::NEG_INFINITY, }) .collect() }) .collect(); /// The index of the largest value, and by how much it leads the next. fn best(values: impl Iterator) -> Option<(usize, f32, f32)> { let mut first: Option<(usize, f32)> = None; let mut second = f32::NEG_INFINITY; for (at, v) in values.enumerate() { match first { Some((_, top)) if v <= top => second = second.max(v), _ => { if let Some((_, top)) = first { second = top; } first = Some((at, v)); } } } first.map(|(at, top)| (at, top, second)) } let decisive = |top: f32, second: f32| top >= SAME_FACE_ACROSS_DEVICES && top - second >= DECISIVE_MARGIN; let claimed: std::collections::HashSet = taken.iter().flatten().copied().collect(); let mut pairs = Vec::new(); for (i, row) in cos.iter().enumerate() { if taken[i].is_some() { continue; } let Some((j, top, second)) = best(row.iter().copied()) else { continue; }; if claimed.contains(&j) || !decisive(top, second) { continue; } let Some((back, top, second)) = best(cos.iter().map(|row| row[j])) else { continue; }; if back == i && decisive(top, second) { pairs.push((i, j)); } } pairs } /// The index the local half of [`match_faces`] is read from: every column /// it asks of a face, so the walk touches no `faces` row. /// /// A face row is eight kilobytes and `model_id` sits past the embedding, so /// reading it opened the row's overflow pages: 38 ms of every sync pass on /// the reference library, to read 19,000 boxes. From this index, 8 ms. The /// other device's half has no such index to use -- it is a snapshot made by /// whatever build that device runs -- but its rows have had their crops /// stripped, which is most of their width. /// /// Created on first use rather than by a migration, like /// `keywords::ensure_term_index` and for the reason given there: a new /// schema version makes older builds refuse this catalog's snapshot, and an /// extra index is invisible to them. The first merge after an upgrade pays /// for building it, once. A failure is logged and the merge goes on reading /// rows, as it did before. pub(crate) fn ensure_face_box_index(tx: &Connection) { if let Err(e) = tx.execute_batch( "CREATE INDEX IF NOT EXISTS main.faces_box ON faces(image_id, model_id, x, y, w, h);", ) { log::warn!("merge: could not create faces_box: {e}"); } } /// Intersection over union of two `(x, y, w, h)` boxes. pub(crate) fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { let x0 = a.0.max(b.0); let y0 = a.1.max(b.1); let x1 = (a.0 + a.2).min(b.0 + b.2); let y1 = (a.1 + a.3).min(b.1 + b.3); let inter = (x1 - x0).max(0.0) * (y1 - y0).max(0.0); let union = a.2 * a.3 + b.2 * b.3 - inter; if union <= 0.0 { 0.0 } else { inter / union } } #[cfg(test)] mod tests { use super::*; use crate::schema; #[test] fn a_collection_we_lack_is_taken_from_remote() { assert_eq!( verdict(None, (1, 100), false), MergeVerdict::InsertedFromRemote ); } #[test] fn higher_remote_revision_wins() { assert_eq!( verdict(Some((3, 100)), (4, 50), false), MergeVerdict::UpdatedFromRemote ); } #[test] fn a_skewed_clock_cannot_beat_a_higher_local_revision() { // The remote's timestamp is far in the future, but it has seen fewer // edits. Revision decides, so the skewed device does not silently // overwrite real work. assert_eq!( verdict(Some((9, 100)), (2, 999_999), false), MergeVerdict::KeptLocal ); } #[test] fn equal_revisions_break_on_timestamp() { assert_eq!( verdict(Some((3, 100)), (3, 200), false), MergeVerdict::UpdatedFromRemote ); assert_eq!( verdict(Some((3, 200)), (3, 100), false), MergeVerdict::KeptLocal ); } #[test] fn an_identical_collection_is_stable() { // Merging twice must not oscillate or report spurious changes. assert_eq!( verdict(Some((3, 100)), (3, 100), false), MergeVerdict::KeptLocal ); } #[test] fn deletion_competes_on_revision_like_any_other_edit() { // Remote deleted it at revision 5; we renamed it at revision 4. The // deletion is newer, so it wins. assert_eq!( verdict(Some((4, 100)), (5, 100), true), MergeVerdict::DeletedByRemote ); // But a stale deletion does not undo a newer local edit. assert_eq!( verdict(Some((6, 100)), (5, 100), true), MergeVerdict::KeptLocal ); } #[test] fn a_tombstone_for_something_we_never_had_is_recorded() { // Otherwise this device could reintroduce the collection to a third. assert_eq!(verdict(None, (2, 100), true), MergeVerdict::DeletedByRemote); } // ---- integration over two real catalogs ------------------------------ /// A fresh device takes the capture dates a peer's sweep read, matched by /// `oc:fileid`, and never overwrites a date it read for itself. #[test] fn capture_metadata_arrives_for_undated_images_only() { let c = two_catalogs(); // Three photographs on both devices: 1 undated here and dated there; // 2 dated on both, differently; 3 undated on both. for id in 1..=3 { add_image_without_hash(&c, "main", id); add_image_without_hash(&c, "remote_cat", id + 10); add_remote_id(&c, "main", id, 100 + id); add_remote_id(&c, "remote_cat", id + 10, 100 + id); } c.execute( "UPDATE remote_cat.images SET captured_at = 1000, captured_offset = 60, camera = 'X', metadata_state = 2 WHERE id = 11", [], ) .unwrap(); c.execute( "UPDATE remote_cat.images SET captured_at = 2000, metadata_state = 2 WHERE id = 12", [], ) .unwrap(); c.execute( "UPDATE main.images SET captured_at = 2222, metadata_state = 2 WHERE id = 2", [], ) .unwrap(); let report = merge_metadata(&c).unwrap(); assert_eq!(report.metadata_adopted, 1); let row = |id: i64| -> (Option, Option, Option, i64) { c.query_row( "SELECT captured_at, captured_offset, camera, metadata_state FROM main.images WHERE id = ?1", [id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .unwrap() }; assert_eq!(row(1), (Some(1000), Some(60), Some("X".into()), 2)); assert_eq!(row(2), (Some(2222), None, None, 2)); assert_eq!(row(3), (None, None, None, 0)); // Idempotent: a second pass finds nothing left to take. assert_eq!(merge_metadata(&c).unwrap().metadata_adopted, 0); } fn two_catalogs() -> Connection { attached_remote(schema::for_attached("remote_cat")) } /// The same pair, but with the remote stopped at v1 — a device running a /// build from before keywords had identities. fn two_catalogs_with_a_v1_remote() -> Connection { attached_remote(schema::v1_for_attached("remote_cat")) } fn attached_remote(remote_schema: String) -> Connection { let c = Connection::open_in_memory().unwrap(); schema::configure(&c).unwrap(); schema::migrate(&c).unwrap(); // A second in-memory database standing in for the downloaded remote. c.execute_batch("ATTACH ':memory:' AS remote_cat").unwrap(); c.execute_batch(&remote_schema).unwrap(); c } /// An image with no content hash — which is every image in a library that /// has only ever been scanned. fn add_image_without_hash(c: &Connection, db: &str, id: i64) { c.execute( &format!( "INSERT INTO {db}.roots(id, kind, label) VALUES (1, 'remote', 'lib') ON CONFLICT(id) DO NOTHING" ), [], ) .unwrap(); c.execute( &format!( "INSERT INTO {db}.images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)" ), rusqlite::params![id, format!("img{id}.CR3")], ) .unwrap(); } fn count(c: &Connection, sql: &str) -> i64 { c.query_row(sql, [], |r| r.get(0)).unwrap() } /// Give an image the `oc:fileid` a remote scan records for it. /// /// What every image in a real synced library has, and what none of them /// has a content hash for. fn add_remote_id(c: &Connection, db: &str, image_id: i64, file_id: i64) { c.execute( &format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"), rusqlite::params![image_id, file_id], ) .unwrap(); } fn add_image(c: &Connection, db: &str, id: i64, hash: &str) { c.execute( &format!( "INSERT INTO {db}.roots(id, kind, label) VALUES (1, 'local', 'r') ON CONFLICT(id) DO NOTHING" ), [], ) .unwrap(); c.execute( &format!( "INSERT INTO {db}.images(id, root_id, source_ref, content_hash, added_at) VALUES (?1, 1, ?2, ?3, 0)" ), rusqlite::params![id, format!("img{id}.CR3"), hash], ) .unwrap(); } fn add_collection(c: &Connection, db: &str, id: i64, uuid: &str, name: &str, rev: i64) { c.execute( &format!( "INSERT INTO {db}.collections(id, uuid, name, kind, created, revision, modified) VALUES (?1, ?2, ?3, 0, 0, ?4, ?4)" ), rusqlite::params![id, uuid, name, rev], ) .unwrap(); } /// The parent's uuid for `uuid`, or None if it sits at the top level. fn parent_of(c: &Connection, uuid: &str) -> Option { c.query_row( "SELECT p.uuid FROM main.collections ch JOIN main.collections p ON p.id = ch.parent_id WHERE ch.uuid = ?1", [uuid], |r| r.get(0), ) .ok() } #[test] fn a_nested_collection_arrives_still_nested() { // The tree used to flatten on every sync: `parent_id` is a local row id, // and the merge wrote NULL rather than translating it through the uuid. let c = two_catalogs(); add_collection(&c, "remote_cat", 1, "uuid-holidays", "holidays", 1); add_collection(&c, "remote_cat", 2, "uuid-arosa", "arosa", 1); c.execute( "UPDATE remote_cat.collections SET parent_id = 1 WHERE id = 2", [], ) .unwrap(); merge_collections(&c).unwrap(); assert_eq!( parent_of(&c, "uuid-arosa").as_deref(), Some("uuid-holidays"), "a sub-collection must not be promoted to the top level by a merge" ); } #[test] fn a_parent_arriving_after_its_child_still_adopts_it() { // Row order is whatever the query returns, so the child can be inserted // first. Parentage is applied in a second pass for exactly this reason. let c = two_catalogs(); // Child has the lower id, so it is seen before the parent exists. add_collection(&c, "remote_cat", 1, "uuid-child", "arosa", 1); add_collection(&c, "remote_cat", 2, "uuid-parent", "holidays", 1); c.execute( "UPDATE remote_cat.collections SET parent_id = 2 WHERE id = 1", [], ) .unwrap(); merge_collections(&c).unwrap(); assert_eq!( parent_of(&c, "uuid-child").as_deref(), Some("uuid-parent"), "resolution must not depend on the order rows happen to arrive in" ); } #[test] fn disagreeing_devices_cannot_close_a_parent_cycle() { // Local holds A above B; the remote holds B above A and wins on // revision. Applying that blindly makes each the other's parent, and // every tree walk then spins. let c = two_catalogs(); add_collection(&c, "main", 1, "uuid-a", "A", 1); add_collection(&c, "main", 2, "uuid-b", "B", 1); c.execute("UPDATE main.collections SET parent_id = 1 WHERE id = 2", []) .unwrap(); // Remote: A under B, at a higher revision so it is the winner. add_collection(&c, "remote_cat", 1, "uuid-a", "A", 9); add_collection(&c, "remote_cat", 2, "uuid-b", "B", 9); c.execute( "UPDATE remote_cat.collections SET parent_id = 2 WHERE id = 1", [], ) .unwrap(); merge_collections(&c).unwrap(); let a = parent_of(&c, "uuid-a"); let b = parent_of(&c, "uuid-b"); assert!( !(a.as_deref() == Some("uuid-b") && b.as_deref() == Some("uuid-a")), "merge closed a cycle: A parented under B and B under A" ); } #[test] fn disjoint_collections_from_two_devices_both_survive() { // The property the whole design exists for: neither device loses work. let c = two_catalogs(); add_collection(&c, "main", 1, "uuid-local", "Iceland", 1); add_collection(&c, "remote_cat", 1, "uuid-remote", "Portugal", 1); let report = merge_collections(&c).unwrap(); assert_eq!(report.inserted, 1); let names: Vec = c .prepare("SELECT name FROM main.collections ORDER BY name") .unwrap() .query_map([], |r| r.get(0)) .unwrap() .collect::>() .unwrap(); assert_eq!(names, vec!["Iceland", "Portugal"]); } #[test] fn membership_unions_rather_than_replacing() { // Two devices each added a different image to the same collection. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); add_image(&c, "main", 1, "hash-a"); add_image(&c, "main", 2, "hash-b"); add_image(&c, "remote_cat", 1, "hash-b"); c.execute( "INSERT INTO main.collection_members(collection_id, image_id, added) VALUES (1, 1, 0)", [], ) .unwrap(); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 1, 0)", [], ) .unwrap(); merge_collections(&c).unwrap(); let n: i64 = c .query_row("SELECT count(*) FROM main.collection_members", [], |r| { r.get(0) }) .unwrap(); assert_eq!(n, 2, "both devices' additions survive"); } #[test] fn a_library_that_never_computed_a_hash_still_merges_membership() { // The bug this replaces. `content_hash` is computed only by import // dedup or a reconnect — never by a scan — so a synced library has one // for no image at all. Keying membership on it alone meant collections // arrived with their names and none of their contents, on every device, // for every user. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); // No hashes anywhere, and a file id for both — a real library. add_image_without_hash(&c, "main", 77); add_image_without_hash(&c, "remote_cat", 3); add_remote_id(&c, "main", 77, 90210); add_remote_id(&c, "remote_cat", 3, 90210); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 3, 0)", [], ) .unwrap(); let report = merge_collections(&c).unwrap(); assert_eq!(report.members_added, 1); let img: i64 = c .query_row("SELECT image_id FROM main.collection_members", [], |r| { r.get(0) }) .unwrap(); assert_eq!(img, 77, "resolved through the server's file id"); } #[test] fn a_different_photograph_on_the_server_is_not_adopted() { // The file id is an identity, so two different ids must not join. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); add_image_without_hash(&c, "main", 77); add_image_without_hash(&c, "remote_cat", 3); add_remote_id(&c, "main", 77, 111); add_remote_id(&c, "remote_cat", 3, 222); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 3, 0)", [], ) .unwrap(); let report = merge_collections(&c).unwrap(); assert_eq!(report.members_added, 0); assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 0); } #[test] fn an_image_identified_both_ways_is_added_once() { // Both statements run, and their overlap must be free rather than a // constraint violation or a double count. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); add_image(&c, "main", 77, "same-photo"); add_image(&c, "remote_cat", 3, "same-photo"); add_remote_id(&c, "main", 77, 90210); add_remote_id(&c, "remote_cat", 3, 90210); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 3, 0)", [], ) .unwrap(); merge_collections(&c).unwrap(); assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 1); } #[test] fn membership_maps_across_devices_by_content_hash() { // The same photograph carries different integer ids on each device. // Keying on the id would attach the wrong image. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); add_image(&c, "main", 77, "same-photo"); add_image(&c, "remote_cat", 3, "same-photo"); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 3, 0)", [], ) .unwrap(); merge_collections(&c).unwrap(); let img: i64 = c .query_row("SELECT image_id FROM main.collection_members", [], |r| { r.get(0) }) .unwrap(); assert_eq!(img, 77, "resolved to the local id for the same photo"); } #[test] fn an_image_we_do_not_have_yet_is_skipped_not_errored() { let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Trip", 1); add_collection(&c, "remote_cat", 1, "shared", "Trip", 1); add_image(&c, "remote_cat", 1, "not-here-yet"); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 1, 0)", [], ) .unwrap(); let report = merge_collections(&c).unwrap(); assert_eq!(report.members_added, 0); // It joins on a later merge, once a scan has catalogued the file. } #[test] fn a_remote_deletion_does_not_resurrect_via_membership() { let c = two_catalogs(); add_collection(&c, "main", 1, "doomed", "Old", 1); add_image(&c, "main", 1, "hash-a"); add_image(&c, "remote_cat", 1, "hash-a"); c.execute( "INSERT INTO remote_cat.collections(id, uuid, name, kind, created, revision, modified, deleted) VALUES (1, 'doomed', 'Old', 0, 0, 5, 5, 1)", [], ) .unwrap(); c.execute( "INSERT INTO remote_cat.collection_members(collection_id, image_id, added) VALUES (1, 1, 0)", [], ) .unwrap(); let report = merge_collections(&c).unwrap(); assert_eq!(report.deleted, 1); let n: i64 = c .query_row("SELECT count(*) FROM main.collection_members", [], |r| { r.get(0) }) .unwrap(); assert_eq!(n, 0, "membership must not repopulate a deleted collection"); } #[test] fn merging_twice_changes_nothing_the_second_time() { let c = two_catalogs(); add_collection(&c, "remote_cat", 1, "uuid-r", "Portugal", 1); let first = merge_collections(&c).unwrap(); assert!(first.local_changed()); let second = merge_collections(&c).unwrap(); assert!(!second.local_changed(), "merge must be idempotent"); } // ---- keywords -------------------------------------------------------- /// Give an image a default version, as every write path assumes it has. fn add_version(c: &Connection, db: &str, image: i64, uuid: &str) -> i64 { c.execute( &format!( "INSERT INTO {db}.versions(image_id, uuid, name, is_default) VALUES (?1, ?2, 'Default', 1)" ), rusqlite::params![image, uuid], ) .unwrap(); c.last_insert_rowid() } /// Put a word on an image's default version, creating the version. /// /// The version uuid is derived from the database *and* the image, so the /// two catalogs never accidentally agree on one — which is the real /// situation, and the reason the assignment union cannot key on it. fn keyword(c: &Connection, db: &str, image: i64, word: &str) { let existing: Option = c .query_row( &format!("SELECT id FROM {db}.versions WHERE image_id = ?1 AND is_default = 1"), [image], |r| r.get(0), ) .ok(); let version = existing.unwrap_or_else(|| add_version(c, db, image, &format!("v-{db}-{image}"))); c.execute( &format!("INSERT OR IGNORE INTO {db}.keywords(version_id, keyword) VALUES (?1, ?2)"), rusqlite::params![version, word], ) .unwrap(); } fn add_term(c: &Connection, db: &str, uuid: &str, name: &str, rev: i64, deleted: i64) { c.execute( &format!( "INSERT INTO {db}.keyword_terms(uuid, name, created, revision, modified, deleted) VALUES (?1, ?2, 0, ?3, ?3, ?4)" ), rusqlite::params![uuid, name, rev, deleted], ) .unwrap(); } /// Map an image to a server file id, as a remote scan does. fn add_file_id(c: &Connection, db: &str, image: i64, file_id: i64) { c.execute( &format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"), rusqlite::params![image, file_id], ) .unwrap(); } /// Every word on an image locally, sorted. fn words_on(c: &Connection, image: i64) -> Vec { let mut stmt = c .prepare( "SELECT DISTINCT k.keyword FROM main.keywords k JOIN main.versions v ON v.id = k.version_id WHERE v.image_id = ?1 ORDER BY k.keyword", ) .unwrap(); let rows = stmt.query_map([image], |r| r.get(0)).unwrap(); rows.collect::, _>>().unwrap() } fn live_terms(c: &Connection) -> Vec { let mut stmt = c .prepare("SELECT name FROM main.keyword_terms WHERE deleted = 0 ORDER BY name") .unwrap(); let rows = stmt.query_map([], |r| r.get(0)).unwrap(); rows.collect::, _>>().unwrap() } #[test] fn two_devices_keywording_different_photographs_both_survive() { // FR-NC-9's principle applied to metadata: disjoint work merges to the // union, and neither device loses an afternoon to whoever synced last. let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); add_image(&c, db, 2, "hash-b"); } keyword(&c, "main", 1, "puffin"); keyword(&c, "remote_cat", 2, "gannet"); merge_keywords(&c).unwrap(); assert_eq!(words_on(&c, 1), ["puffin"]); assert_eq!(words_on(&c, 2), ["gannet"]); } #[test] fn two_devices_keywording_one_photograph_keep_both_words() { // The case the union is really for: the same frame, two different // words, and last-writer-wins would silently drop one of them. let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } keyword(&c, "main", 1, "puffin"); keyword(&c, "remote_cat", 1, "Iceland"); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_assigned, 1); assert_eq!(words_on(&c, 1), ["Iceland", "puffin"]); } #[test] fn a_word_both_devices_already_had_is_not_duplicated() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); keyword(&c, db, 1, "puffin"); } let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_assigned, 0); assert_eq!(words_on(&c, 1), ["puffin"]); } #[test] fn keywords_reach_an_image_the_server_names_but_no_one_has_hashed() { // `content_hash` is computed only when import dedup or a reconnect asks // for it, so for most images it is NULL — and a union keyed on it alone // would quietly do nothing for the ordinary photograph. The file id is // recorded by every remote scan, which is exactly the situation where // two devices are keywording one library. let c = two_catalogs(); c.execute( "INSERT INTO main.roots(id, kind, label) VALUES (1, 'remote', 'r')", [], ) .unwrap(); c.execute( "INSERT INTO remote_cat.roots(id, kind, label) VALUES (1, 'remote', 'r')", [], ) .unwrap(); // Different row ids for one photograph, and no hash on either side. c.execute( "INSERT INTO main.images(id, root_id, source_ref, added_at) VALUES (77, 1, 'IMG_1.CR3', 0)", [], ) .unwrap(); c.execute( "INSERT INTO remote_cat.images(id, root_id, source_ref, added_at) VALUES (3, 1, 'IMG_1.CR3', 0)", [], ) .unwrap(); add_file_id(&c, "main", 77, 9001); add_file_id(&c, "remote_cat", 3, 9001); add_version(&c, "main", 77, "v-main"); keyword(&c, "remote_cat", 3, "puffin"); merge_keywords(&c).unwrap(); assert_eq!(words_on(&c, 77), ["puffin"]); } #[test] fn keywords_map_across_devices_by_content_hash_where_there_is_no_server() { // A local-only library has no `remote` rows at all, so the hash is the // only identity available — and it is the one membership already uses. let c = two_catalogs(); add_image(&c, "main", 77, "same-photo"); add_image(&c, "remote_cat", 3, "same-photo"); add_version(&c, "main", 77, "v-main"); keyword(&c, "remote_cat", 3, "puffin"); merge_keywords(&c).unwrap(); assert_eq!(words_on(&c, 77), ["puffin"]); } #[test] fn the_vocabulary_merges_by_uuid_and_a_skewed_clock_cannot_win() { let c = two_catalogs(); add_term(&c, "main", "u-1", "Iceland", 9, 0); add_term(&c, "remote_cat", "u-1", "iceland", 2, 0); add_term(&c, "remote_cat", "u-2", "puffin", 1, 0); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_kept_local, 1); assert_eq!(report.keywords_inserted, 1); assert_eq!(live_terms(&c), ["Iceland", "puffin"]); } #[test] fn a_remote_rename_moves_this_device_s_assignments_too() { // The failure this exists to stop: the vocabulary shows the corrected // spelling and the search still only finds the old one. let c = two_catalogs(); add_image(&c, "main", 1, "hash-a"); add_term(&c, "main", "u-1", "Icland", 1, 0); keyword(&c, "main", 1, "Icland"); add_term(&c, "remote_cat", "u-1", "Iceland", 4, 0); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_updated, 1); assert_eq!(live_terms(&c), ["Iceland"]); assert_eq!(words_on(&c, 1), ["Iceland"]); } #[test] fn a_remote_deletion_takes_the_word_off_every_photograph() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } add_term(&c, "main", "u-1", "blurry", 1, 0); keyword(&c, "main", 1, "blurry"); add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_deleted, 1); assert!(live_terms(&c).is_empty()); assert!(words_on(&c, 1).is_empty()); } #[test] fn a_deletion_is_not_undone_by_the_union_on_the_same_pass() { // The remote deleted the word *and* still carries assignments for it — // it has not yet had the chance to sweep them, or a third device put // them there. Without the tombstone filter the union would put the word // straight back on the photograph the deletion had just cleared. let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } add_term(&c, "main", "u-1", "blurry", 1, 0); keyword(&c, "main", 1, "blurry"); add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); keyword(&c, "remote_cat", 1, "blurry"); merge_keywords(&c).unwrap(); assert!(words_on(&c, 1).is_empty(), "a deleted keyword came back"); } #[test] fn a_deletion_lands_even_when_the_two_devices_minted_different_uuids() { // Both typed "blurry" before they ever synced, so this device's row has // a uuid the remote has never heard of. Deleting by identity would // tombstone nothing and leave every photograph still carrying the word. let c = two_catalogs(); add_image(&c, "main", 1, "hash-a"); add_term(&c, "main", "mine", "blurry", 1, 0); keyword(&c, "main", 1, "blurry"); add_term(&c, "remote_cat", "theirs", "blurry", 5, 1); merge_keywords(&c).unwrap(); assert!(words_on(&c, 1).is_empty()); } #[test] fn two_devices_that_typed_one_word_end_up_with_one_keyword() { // Neither is wrong until they meet, which is why the name carries no // unique index — a constraint would abort the merge at this moment. let c = two_catalogs(); add_term(&c, "main", "zzzz", "Iceland", 3, 0); add_term(&c, "remote_cat", "aaaa", "Iceland", 1, 0); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_fused, 1); assert_eq!(live_terms(&c), ["Iceland"]); let survivor: String = c .query_row("SELECT uuid FROM main.keyword_terms", [], |r| r.get(0)) .unwrap(); assert_eq!( survivor, "aaaa", "both devices must pick the same survivor without asking each other" ); } #[test] fn merging_keywords_twice_changes_nothing_the_second_time() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } add_term(&c, "remote_cat", "u-1", "puffin", 1, 0); keyword(&c, "remote_cat", 1, "puffin"); let first = merge_keywords(&c).unwrap(); assert!(first.local_changed()); let second = merge_keywords(&c).unwrap(); assert!(!second.local_changed(), "merge must be idempotent"); } #[test] fn a_remote_from_before_keyword_identities_still_contributes_its_words() { // `remote_is_mergeable` admits an older remote on purpose — the check // is that it is not *newer* than us. A missing table is therefore a // normal state and must not fail the merge. let c = two_catalogs_with_a_v1_remote(); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } keyword(&c, "remote_cat", 1, "puffin"); let report = merge_keywords(&c).unwrap(); assert_eq!(report.keywords_assigned, 1); assert_eq!(words_on(&c, 1), ["puffin"]); } #[test] fn merge_all_lands_both_halves() { let c = two_catalogs(); add_collection(&c, "remote_cat", 1, "u-coll", "Portugal", 1); for db in ["main", "remote_cat"] { add_image(&c, db, 1, "hash-a"); } keyword(&c, "remote_cat", 1, "puffin"); let report = merge_all(&c).unwrap(); assert_eq!(report.inserted, 1); assert_eq!(report.keywords_assigned, 1); } #[test] fn keeping_local_still_marks_the_catalog_for_upload() { // We hold something the remote does not, so the remote is stale even // though we took nothing from it. let c = two_catalogs(); add_collection(&c, "main", 1, "shared", "Renamed here", 5); add_collection(&c, "remote_cat", 1, "shared", "Old name", 2); let report = merge_collections(&c).unwrap(); assert_eq!(report.kept_local, 1); assert!(report.should_upload()); } // ── people, and who the user said they are ──────────────────────────── /// An image present in `db` and carrying the cross-device file id both /// catalogs agree on. fn add_synced_image(c: &Connection, db: &str, id: i64, file_id: i64) { add_image_without_hash(c, db, id); c.execute( &format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"), rusqlite::params![id, file_id], ) .unwrap(); } /// A face on `image`, at a box the caller can nudge to test the matching. fn add_face(c: &Connection, db: &str, id: i64, image: i64, x: f64) -> i64 { c.execute( &format!( "INSERT INTO {db}.faces (id, image_id, x, y, w, h, landmarks, detector_confidence, embedding, crop_px, model_id, detected_at) VALUES (?1, ?2, ?3, 0.2, 0.2, 0.2, X'00', 0.9, X'00', 150.0, 'w600k_mbf', 0)" ), rusqlite::params![id, image, x], ) .unwrap(); id } fn add_person(c: &Connection, db: &str, id: i64, uuid: &str, name: &str, ignored: bool) { c.execute( &format!( "INSERT INTO {db}.people(id, uuid, name, ignored, created, revision, modified) VALUES (?1, ?2, ?3, ?4, 0, 1, 1)" ), rusqlite::params![id, uuid, name, ignored], ) .unwrap(); } fn assign(c: &Connection, db: &str, face: i64, person: i64, confirmed: bool) { c.execute( &format!( "INSERT INTO {db}.face_person(face_id, person_id, probability, confirmed) VALUES (?1, ?2, 0.9, ?3)" ), rusqlite::params![face, person, confirmed], ) .unwrap(); } fn person_of(c: &Connection, face: i64) -> Option<(String, bool)> { c.query_row( "SELECT p.name, fp.confirmed FROM main.face_person fp JOIN main.people p ON p.id = fp.person_id WHERE fp.face_id = ?1", [face], |r| Ok((r.get(0)?, r.get(1)?)), ) .optional() .unwrap() } /// The bug: a second device received every face through the shards and no /// people at all, because this merge only ever looked at collections and /// keywords. It drew an empty People screen over a full catalog. #[test] fn a_named_person_and_their_confirmed_face_cross_over() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } // The same face, found independently on each device, so the row ids // differ — which is the whole difficulty. let local = add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.31); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "remote_cat", remote, 3, true); let report = merge_all(&c).unwrap(); assert_eq!(report.people_inserted, 1); assert_eq!(report.faces_assigned, 1); assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true))); } /// The bug this rule exists for: the desktop switched to a stronger /// detector and confirmed 3,500 faces under the old pipeline id; the /// tablet held the same faces under the new one, and not one name /// crossed, because the match demanded the exact id. Same photograph, /// same box, same embedder — that is the same face. #[test] fn a_confirmation_crosses_a_detector_change() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); c.execute( "UPDATE main.faces SET model_id = 'scrfd_10g+w600k_mbf' WHERE id = ?1", [local], ) .unwrap(); let remote = add_face(&c, "remote_cat", 42, 1, 0.31); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "remote_cat", remote, 3, true); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_assigned, 1); assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true))); } /// A different embedder is a different space, and a box there is a face /// nobody here has a vector for. #[test] fn a_confirmation_does_not_cross_an_embedder_change() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); c.execute( "UPDATE main.faces SET model_id = 'scrfd_10g+other_embedder' WHERE id = ?1", [local], ) .unwrap(); let remote = add_face(&c, "remote_cat", 42, 1, 0.31); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "remote_cat", remote, 3, true); merge_all(&c).unwrap(); assert_eq!(person_of(&c, local), None, "matched across embedders"); } /// Boxes from two devices are close but not identical. Matching has to be /// by overlap, not equality, or nothing ever lines up. #[test] fn a_face_in_a_different_photograph_is_not_matched() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); add_synced_image(&c, db, 2, 6000); } let elsewhere = add_face(&c, "main", 7, 2, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "remote_cat", remote, 3, true); merge_all(&c).unwrap(); assert_eq!(person_of(&c, elsewhere), None, "matched across photographs"); } /// Two faces in one frame, and the judgement must land on the right one. #[test] fn the_overlapping_face_is_the_one_that_gets_the_name() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let left = add_face(&c, "main", 7, 1, 0.10); let right = add_face(&c, "main", 8, 1, 0.70); let remote = add_face(&c, "remote_cat", 42, 1, 0.71); add_person(&c, "remote_cat", 3, "u-bob", "Bob", false); assign(&c, "remote_cat", remote, 3, true); merge_all(&c).unwrap(); assert_eq!(person_of(&c, right), Some(("Bob".to_string(), true))); assert_eq!(person_of(&c, left), None); } /// A group set aside on one device stays set aside on the other — which /// needs its *suggestions* to travel, since that is what anchors it. #[test] fn a_group_set_aside_stays_set_aside_on_the_other_device() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "remote_cat", 3, "u-stranger", "", true); // Only ever a suggestion, which is exactly why it needs carrying. assign(&c, "remote_cat", remote, 3, false); merge_all(&c).unwrap(); let ignored: bool = c .query_row( "SELECT ignored FROM main.people WHERE uuid = 'u-stranger'", [], |r| r.get(0), ) .unwrap(); assert!(ignored, "the set-aside flag did not travel"); assert_eq!(person_of(&c, local), Some((String::new(), false))); } /// An ordinary suggestion is this pass's own output. Both devices hold the /// same embeddings and clustering is deterministic, so each recomputes it — /// shipping it would double the merge for no new information. #[test] fn an_ordinary_suggestion_does_not_travel() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "remote_cat", 3, "u-guess", "", false); assign(&c, "remote_cat", remote, 3, false); merge_all(&c).unwrap(); assert_eq!(person_of(&c, local), None); } /// A sync must not undo what the user did on the device they are holding. #[test] fn a_local_confirmation_outranks_the_remotes() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "main", 1, "u-anna", "Anna", false); add_person(&c, "remote_cat", 3, "u-bob", "Bob", false); assign(&c, "main", local, 1, true); assign(&c, "remote_cat", remote, 3, true); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_kept_local, 1); assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true))); } /// "Not this person" is a judgement too, and it has to outrank an /// assignment arriving from elsewhere. #[test] fn a_rejection_travels_and_removes_the_suggestion_it_contradicts() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "main", 1, "u-anna", "Anna", false); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); // Locally suggested; the other device says it is not her. assign(&c, "main", local, 1, false); c.execute( "INSERT INTO remote_cat.face_person_rejected(face_id, person_id) VALUES (?1, 3)", [remote], ) .unwrap(); merge_all(&c).unwrap(); assert_eq!(person_of(&c, local), None, "the refused suggestion stayed"); let rejected: bool = c .query_row( "SELECT EXISTS(SELECT 1 FROM main.face_person_rejected WHERE face_id = ?1)", [local], |r| r.get(0), ) .unwrap(); assert!(rejected); } /// A rename on the other device wins on revision, like everything else. #[test] fn a_higher_revision_renames_a_person() { let c = two_catalogs(); add_person(&c, "main", 1, "u-anna", "Ana", false); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); c.execute( "UPDATE remote_cat.people SET revision = 5, modified = 5 WHERE uuid = 'u-anna'", [], ) .unwrap(); let report = merge_all(&c).unwrap(); assert_eq!(report.people_updated, 1); let name: String = c .query_row( "SELECT name FROM main.people WHERE uuid = 'u-anna'", [], |r| r.get(0), ) .unwrap(); assert_eq!(name, "Anna"); } #[test] fn a_lower_revision_does_not_rename_a_person() { let c = two_catalogs(); add_person(&c, "main", 1, "u-anna", "Anna", false); add_person(&c, "remote_cat", 3, "u-anna", "Ana", false); c.execute("UPDATE main.people SET revision = 9, modified = 9", []) .unwrap(); let report = merge_all(&c).unwrap(); assert_eq!(report.people_kept_local, 1); let name: String = c .query_row( "SELECT name FROM main.people WHERE uuid = 'u-anna'", [], |r| r.get(0), ) .unwrap(); assert_eq!(name, "Anna"); } /// Merging twice must not double anything — a sync runs on every pass. #[test] fn merging_people_twice_changes_nothing_the_second_time() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } add_face(&c, "main", 7, 1, 0.30); let remote = add_face(&c, "remote_cat", 42, 1, 0.30); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "remote_cat", remote, 3, true); merge_all(&c).unwrap(); let second = merge_all(&c).unwrap(); assert_eq!(second.people_inserted, 0); let people: i64 = c .query_row("SELECT COUNT(*) FROM main.people", [], |r| r.get(0)) .unwrap(); assert_eq!(people, 1); } // ── faces the boxes cannot place, and their vectors ─────────────────── /// A unit vector in the embedder's space, the same for the same seed. /// Two seeds are near-orthogonal, as two strangers' faces are. fn vector(seed: u32) -> Vec { let mut s = seed.wrapping_mul(2_654_435_761).wrapping_add(1); let mut v: Vec = (0..dr_face::EMBEDDING_DIM) .map(|_| { s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); (s >> 8) as f32 / (1u32 << 23) as f32 - 0.5 }) .collect(); let norm = v.iter().map(|x| x * x).sum::().sqrt(); v.iter_mut().for_each(|x| *x /= norm); v } /// Store `v` as `face`'s embedding, as the embedder would. fn embed(c: &Connection, db: &str, face: i64, v: &[f32]) { let e = dr_face::Embedding { model: dr_face::ModelId::new("w600k_mbf"), v: Box::new(v.try_into().unwrap()), }; c.execute( &format!("UPDATE {db}.faces SET embedding = ?2 WHERE id = ?1"), rusqlite::params![face, e.to_f16_bytes()], ) .unwrap(); } /// Anna confirmed on the remote's face 42. fn anna_on(c: &Connection, remote: i64) { add_person(c, "remote_cat", 3, "u-anna", "Anna", false); assign(c, "remote_cat", remote, 3, true); } /// The case #77 was opened for: one device drew the box somewhere else — /// on the reference library, whole photographs whose boxes sit at IoU 0 /// with cosines above 0.9 — and the name stayed behind. The vector says /// it is the same face. #[test] fn a_shifted_box_with_the_same_embedding_matches() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.10); let remote = add_face(&c, "remote_cat", 42, 1, 0.60); embed(&c, "main", local, &vector(1)); embed(&c, "remote_cat", remote, &vector(1)); anna_on(&c, remote); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_matched_by_embedding, 1); assert_eq!(person_of(&c, local), Some(("Anna".to_string(), true))); } /// Two faces close enough that both boxes overlap the remote's by more /// than half: the box cannot say which, and must not guess. The vector /// can. #[test] fn two_overlapping_faces_are_told_apart_by_embedding() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let front = add_face(&c, "main", 7, 1, 0.30); let behind = add_face(&c, "main", 8, 1, 0.36); let remote = add_face(&c, "remote_cat", 42, 1, 0.33); embed(&c, "main", front, &vector(1)); embed(&c, "main", behind, &vector(2)); embed(&c, "remote_cat", remote, &vector(2)); anna_on(&c, remote); merge_all(&c).unwrap(); assert_eq!(person_of(&c, behind), Some(("Anna".to_string(), true))); assert_eq!(person_of(&c, front), None); } /// The same two overlapping boxes, and vectors that do not decide: the /// face stays unmatched rather than going to the larger overlap. #[test] fn an_ambiguous_box_with_no_decisive_vector_stays_unmatched() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let front = add_face(&c, "main", 7, 1, 0.30); let behind = add_face(&c, "main", 8, 1, 0.35); let remote = add_face(&c, "remote_cat", 42, 1, 0.33); embed(&c, "main", front, &vector(1)); embed(&c, "main", behind, &vector(2)); // Equally like both: 0.71 each, no margin. let between: Vec = vector(1) .iter() .zip(vector(2)) .map(|(a, b)| (a + b) / 2f32.sqrt()) .collect(); embed(&c, "remote_cat", remote, &between); anna_on(&c, remote); merge_all(&c).unwrap(); assert_eq!(person_of(&c, front), None); assert_eq!(person_of(&c, behind), None); } /// The same person in another photograph has the same vector — the /// worst lookalike there is — and is not the same face. Nor is a face /// in the right photograph that neither box nor vector ties to it: that /// is a face this device found and the other did not, and it stays new. #[test] fn a_similar_embedding_in_a_different_photograph_never_matches() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); add_synced_image(&c, db, 2, 6000); } let elsewhere = add_face(&c, "main", 7, 2, 0.10); let stranger = add_face(&c, "main", 8, 1, 0.10); let remote = add_face(&c, "remote_cat", 42, 1, 0.60); embed(&c, "main", elsewhere, &vector(1)); embed(&c, "main", stranger, &vector(2)); embed(&c, "remote_cat", remote, &vector(1)); anna_on(&c, remote); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_matched_by_embedding, 0); assert_eq!(person_of(&c, elsewhere), None, "matched across photographs"); assert_eq!(person_of(&c, stranger), None, "a new face was matched"); } /// Vectors from two embedders live in two spaces; a cosine between /// them is a number that means nothing. #[test] fn different_embedders_never_compare() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let local = add_face(&c, "main", 7, 1, 0.10); c.execute( "UPDATE main.faces SET model_id = 'scrfd_10g+other_embedder' WHERE id = ?1", [local], ) .unwrap(); let remote = add_face(&c, "remote_cat", 42, 1, 0.60); embed(&c, "main", local, &vector(1)); embed(&c, "remote_cat", remote, &vector(1)); anna_on(&c, remote); merge_all(&c).unwrap(); assert_eq!(person_of(&c, local), None, "matched across embedders"); } /// A local face its box already placed is not handed to a second remote /// face because that one resembles it: one face, one counterpart. #[test] fn a_face_the_box_placed_is_not_taken_again_by_a_vector() { let c = two_catalogs(); for db in ["main", "remote_cat"] { add_synced_image(&c, db, 1, 5000); } let placed = add_face(&c, "main", 7, 1, 0.10); let free = add_face(&c, "main", 8, 1, 0.70); let by_box = add_face(&c, "remote_cat", 41, 1, 0.10); let remote = add_face(&c, "remote_cat", 42, 1, 0.40); embed(&c, "main", placed, &vector(1)); embed(&c, "main", free, &vector(3)); embed(&c, "remote_cat", by_box, &vector(2)); embed(&c, "remote_cat", remote, &vector(1)); anna_on(&c, remote); merge_all(&c).unwrap(); assert_eq!(person_of(&c, placed), None); assert_eq!(person_of(&c, free), None); } // ── one person, one face per photograph ─────────────────────────────── /// Two faces far apart in one photograph, one each side's remote /// counterpart can be matched to by box: (local 7, local 8, remote 42 /// over 8). fn two_faces_one_photograph(c: &Connection) -> (i64, i64, i64) { for db in ["main", "remote_cat"] { add_synced_image(c, db, 1, 5000); } let here = add_face(c, "main", 7, 1, 0.10); let there = add_face(c, "main", 8, 1, 0.60); let remote = add_face(c, "remote_cat", 42, 1, 0.60); (here, there, remote) } /// The devices disagree about which stranger in a crowd a set-aside /// group holds. Taking the remote's anchor beside this device's own put /// one person on two faces of one frame. #[test] fn a_set_aside_anchor_does_not_land_beside_this_devices_own() { let c = two_catalogs(); let (here, there, remote) = two_faces_one_photograph(&c); add_person(&c, "main", 1, "u-stranger", "", true); add_person(&c, "remote_cat", 3, "u-stranger", "", true); assign(&c, "main", here, 1, false); assign(&c, "remote_cat", remote, 3, false); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_one_per_photograph, 1); assert_eq!(person_of(&c, here), Some((String::new(), false))); assert_eq!(person_of(&c, there), None); } /// A confirmation from the other device outranks a suggestion here for /// the same person on another face, which gives the person up. #[test] fn a_remote_confirmation_moves_a_local_suggestion_off_the_other_face() { let c = two_catalogs(); let (here, there, remote) = two_faces_one_photograph(&c); add_person(&c, "main", 1, "u-anna", "Anna", false); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "main", here, 1, false); assign(&c, "remote_cat", remote, 3, true); merge_all(&c).unwrap(); assert_eq!(person_of(&c, there), Some(("Anna".to_string(), true))); assert_eq!(person_of(&c, here), None); } /// Two confirmations of one person on two faces of one photograph is a /// disagreement no merge can settle; this device's stands, and the /// second is not added beside it. #[test] fn a_remote_confirmation_does_not_double_a_local_one() { let c = two_catalogs(); let (here, there, remote) = two_faces_one_photograph(&c); add_person(&c, "main", 1, "u-anna", "Anna", false); add_person(&c, "remote_cat", 3, "u-anna", "Anna", false); assign(&c, "main", here, 1, true); assign(&c, "remote_cat", remote, 3, true); let report = merge_all(&c).unwrap(); assert_eq!(report.faces_one_per_photograph, 1); assert_eq!(person_of(&c, here), Some(("Anna".to_string(), true))); assert_eq!(person_of(&c, there), None); } }