From f41b3f6e8e33cc2b31af2c304d727c7ab29ec9da Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 28 Aug 2026 23:09:28 +0200 Subject: [PATCH] Answer "what would the other device end up with" without the other device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tablet showed 200 of a person's 611 faces after syncing, and the obvious suspects — that suggestions deliberately do not travel, that the cross-device face match was too strict — were both wrong. Finding that out meant reading a catalog on a release-signed Android build, which cannot be done. So this stands the second device up locally: an empty catalog, given the images a scan would have found, the shards adopted into it exactly as a sync does, and the real catalog merged in as the remote. Then it counts, per person, against what the source holds. person source here Catherine 611 611 Me 242 242 Ian 219 219 Which settled it: the merge carries everything, and the shortfall was transfer — shards that never finished arriving. Worth keeping, because "did the sync lose this or has it not got here yet" is a question that will come up again, and guessing at it cost most of an evening. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/examples/sync_probe.rs | 111 +++++++++++++++++++++++++ docs/traceability.md | 2 +- 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 core/dr-catalog/examples/sync_probe.rs diff --git a/core/dr-catalog/examples/sync_probe.rs b/core/dr-catalog/examples/sync_probe.rs new file mode 100644 index 0000000..b79026c --- /dev/null +++ b/core/dr-catalog/examples/sync_probe.rs @@ -0,0 +1,111 @@ +//! What a second device ends up with after adopting this library. +//! +//! Stands up an empty catalog, gives it the images the real one has, adopts the +//! face shards into it exactly as a sync would, merges the real catalog in as a +//! remote — and then counts. The point is to answer "why does the tablet show +//! fewer faces for this person" without needing the tablet. +//! +//! cargo run -p dr-catalog --example sync_probe -- CATALOG.sqlite FACES_DIR + +use std::path::PathBuf; + +use dr_catalog::face_shard::{self, FaceShardStore}; +use dr_catalog::Catalog; + +const MODEL: &str = "w600k_mbf"; + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.len() < 2 { + eprintln!("usage: sync_probe CATALOG.sqlite FACES_DIR"); + std::process::exit(2); + } + let source = PathBuf::from(&args[0]); + let faces_dir = PathBuf::from(&args[1]); + + let dir = std::env::temp_dir().join(format!("dr-sync-probe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("catalog.sqlite"); + + let far = Catalog::open(&dest).expect("fresh catalog"); + let conn = far.connection(); + + // The images a scan would have found. Nothing else: no faces, no people. + conn.execute( + "ATTACH DATABASE ?1 AS src", + [source.to_string_lossy().as_ref()], + ) + .unwrap(); + // Foreign keys off for the copy: `images` carries self-references + // (`shadowed_by`) that are only consistent once every row is in, and this + // is a bulk clone rather than an edit. + conn.execute_batch( + "PRAGMA foreign_keys = OFF; + INSERT INTO roots SELECT * FROM src.roots; + INSERT INTO images SELECT * FROM src.images; + INSERT INTO remote SELECT * FROM src.remote; + PRAGMA foreign_keys = ON;", + ) + .unwrap(); + let images: i64 = conn + .query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0)) + .unwrap(); + conn.execute_batch("DETACH DATABASE src").unwrap(); + println!("second device starts with {images} image(s), no faces"); + + // Adopt every shard, which is what a completed face sync leaves behind. + let store = FaceShardStore::open(&faces_dir).expect("shard store"); + let adopted = face_shard::import_from_shards(conn, &store, MODEL).expect("import"); + let faces: i64 = conn + .query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0)) + .unwrap(); + println!("adopted {adopted} image(s) from the shards -> {faces} face(s)"); + + // Then the catalog merge, which is where people and their judgements come. + let report = dr_catalog::sync::merge_remote(conn, &source).expect("merge"); + println!( + "merge: {} people in, {} updated, {} kept local, {} face(s) assigned, \ + {} kept local, {} rejection(s)", + report.people_inserted, + report.people_updated, + report.people_kept_local, + report.faces_assigned, + report.faces_kept_local, + report.faces_rejected, + ); + + // Per person, against what the source holds. + conn.execute( + "ATTACH DATABASE ?1 AS src", + [source.to_string_lossy().as_ref()], + ) + .unwrap(); + let mut q = conn + .prepare( + "SELECT p.name, + (SELECT COUNT(*) FROM src.face_person sfp + JOIN src.people sp ON sp.id = sfp.person_id + WHERE sp.uuid = p.uuid) AS there, + (SELECT COUNT(*) FROM face_person fp WHERE fp.person_id = p.id) AS here + FROM people p + WHERE p.name != '' + ORDER BY there DESC LIMIT 12", + ) + .unwrap(); + println!("\n{:<24} {:>8} {:>8}", "person", "source", "here"); + let rows = q + .query_map([], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .unwrap(); + for row in rows.flatten() { + println!("{:<24} {:>8} {:>8}", row.0, row.1, row.2); + } + + println!("\nprobe catalog left at {}", dest.display()); +} diff --git a/docs/traceability.md b/docs/traceability.md index e1c2e53..ca44ba4 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,7 +9,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 277 | +| Source files scanned | 278 | | TRACES tags found | 811 | | Requirements defined | 177 | | Requirements covered | 106 |