Answer "what would the other device end up with" without the other device
Build and test / Desktop (Linux) (push) Successful in 2h7m52s
Build and test / Layer separation (push) Successful in 59s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 37s
Build and test / Android (aarch64) (push) Successful in 22m22s

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 23:09:28 +02:00
co-authored by Claude Opus 5
parent bb3b512b81
commit f41b3f6e8e
2 changed files with 112 additions and 1 deletions
+111
View File
@@ -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<String> = 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());
}
+1 -1
View File
@@ -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 |