From c78b798cf091fa4c74db16ca680515e8f9a9efa4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 26 Sep 2026 17:50:28 -0400 Subject: [PATCH] Fold people of one name whose faces agree, and faces held twice (#78) Seven names are two or three live people on both devices: Ian (756 confirmed faces, and a second Ian with none), Jessie three times, Claudine, Mathias, Noemi, Pascal and PJ. Each was typed on its own device and carried across by sync, which keys people on their uuid and so keeps both. Each half of a person shows half their photographs. dedup_people::run, in one transaction: - Same-name people (trimmed, case-folded as the Identity screen folds them) merge into the one with the most confirmed faces, ties to the smaller uuid, through faces::merge_people_within, so confirmations, rejections and the survivor's name are kept. A person holding no faces at all merges: there is nothing to compare or to carry. Anyone else needs >= 2 confirmed faces per shared embedder on both sides and centroids at cosine >= 0.7 in each. A face confirmed as one and rejected as the other keeps them apart. Unnamed and set-aside people are never merged by name. - Faces held twice (one image, one embedder, IoU >= 0.5, cosine >= 0.7) keep the stronger detector's row (FaceDetector::outranks), then the confirmed one, then the older. The survivor takes the confirmed assignment and both rows' rejections. A pair confirmed as two different people is left and counted. - Judgements still on a merged-away person move to the person at the end of its redirects, and a redirect cycle (two devices merging one pair in opposite directions) is broken at the smaller uuid. Measured on copies of the desktop catalog and the tablet's server snapshot, w600k_mbf, confirmed faces only: - Centroids of differently named people: 2,699 pairs, median 0.02, 99.9th percentile 0.41. One pair reaches 0.70 (0.700 desktop, 0.705 tablet), "Michelle Casanonve" and "Michelle Casanova", one person typed two ways. Next is 0.62/0.64, "Boris Jost" and "Boris". The highest pair that is plainly two people is 0.43/0.44. - One person split in random halves: minimum 0.69, median 0.91 over 72 people. Four faces against twenty-two reach 0.7 in 97% of draws. One face against twenty of somebody else's reached 0.74 in 3,000 draws, and two faces reached 0.61, hence the two-face minimum. - Pascal (22 and 4 confirmed) is at 0.57 and PJ (14 and 7) at 0.50, under 0.7 on both devices, so both pairs stay apart and are logged. The desktop's second Ian holds 4 suggestions and no confirmations, at 0.38 against Ian's centroid, and stays apart. On the tablet it holds nothing and merges. Why a merge made here survives a peer on 0.17.0: the merged-away person stays as a merged_into redirect with a bumped revision, which the catalog merge has always taken on revision. The peer hides the duplicate and never sends it back as a live person. Its own confirmations of that person stay on the redirect, because a merge never overwrites a local confirmation. The manual merge has always left them there too. They follow the redirect when the peer runs this job. A test syncs two catalog files through the previous merge code and back, and the people converge and stay converged. Once a catalog is clean the job reads 80 redirects, the named people, and the face boxes from the covering faces_box index. That is ~10 ms on the reference library. There is no schema change. The index is created IF NOT EXISTS, as the merge already does. --- core/dr-catalog/src/dedup_people.rs | 1227 +++++++++++++++++++++++++++ core/dr-catalog/src/faces.rs | 27 +- core/dr-catalog/src/lib.rs | 1 + core/dr-catalog/src/merge.rs | 4 +- docs/dev/traceability.md | 14 +- 5 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 core/dr-catalog/src/dedup_people.rs diff --git a/core/dr-catalog/src/dedup_people.rs b/core/dr-catalog/src/dedup_people.rs new file mode 100644 index 0000000..8aaa111 --- /dev/null +++ b/core/dr-catalog/src/dedup_people.rs @@ -0,0 +1,1227 @@ +//! TRACES: FR-CULL-10 | FR-CULL-12 | FR-CAT-7 +//! One person per name the faces agree on, and one row per face (#78). +//! +//! Two devices that each typed "Jessie" before they synced hold two people +//! called Jessie, and the merge keeps both, because it keys on the uuid the +//! way collections do (FR-CULL-12: "merging them is the ordinary FR-CULL-10 +//! merge"). Nothing ever performed that merge. On the reference library +//! (2026-09-26) seven names were two or three live people on both devices, +//! each showing half a person's photographs and hiding every confirmation +//! made on the other half. This job performs it, and the same for a face +//! held twice in one photograph, after every sync merge. +//! +//! # How it propagates, and what an older peer does with it +//! +//! A merge here is [`faces::merge_people_within`], the Identity screen's +//! merge: the person merged away stays as a redirect (`merged_into`) with a +//! bumped revision. Every build that syncs people already honours that +//! redirect -- the catalog merge takes it on revision like a rename -- so a +//! peer on the previous release hides the duplicate too and never sends it +//! back as a live person. +//! +//! What that peer does *not* do is move its own confirmations off the +//! person it just learnt was merged away: a local confirmation is never +//! overwritten by a merge, so those faces stay on a person nobody lists +//! until the peer runs this job itself. That is also what a manual merge +//! has always done. This job is the other half: [`follow_redirects`] moves +//! whatever this device holds on a merged-away person onto the one that +//! outlived it, whichever device made the merge. +//! +//! # Deterministic on every device +//! +//! Two devices on this build run the job independently over nearly the same +//! data, and must not merge a pair in opposite directions: X into Y here and +//! Y into X there syncs into two redirects pointing at each other and nobody +//! left. The survivor is the person with the most confirmed faces, ties to +//! the smaller uuid; where the counts differ between the devices and the +//! two choices cross anyway, [`follow_redirects`] breaks the cycle at the +//! smaller uuid, which both devices compute alike. +//! +//! # Cheap when there is nothing to do +//! +//! Every step starts from the small side (docs/dev/catalog.md §1): the +//! 80-odd redirects, the named people, and the face boxes out of the +//! covering `faces_box` index. Vectors are read only for the people of a +//! same-name group and the faces whose boxes already coincide. + +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use rusqlite::Connection; + +use dr_types::FaceDetector; + +use crate::error::CatalogError; +use crate::faces::{self, PersonId}; + +/// Two faces or two people are the same when their vectors (centroids, for +/// a person) reach this cosine -- cosine distance under 0.3. +/// +/// One rule for both halves, set by the user for #78, and the figure #77 +/// measured for one face on two devices +/// (`merge::SAME_FACE_ACROSS_DEVICES`). Measured for people on the +/// reference library (2026-09-26, w600k_mbf, confirmed faces only), desktop +/// and the tablet's snapshot alike: +/// +/// - Between **differently named** people, of 2,699 pairs of centroids the +/// median is 0.02, the 99.9th percentile 0.41, and one pair reaches 0.70: +/// "Michelle Casanonve" and "Michelle Casanova", one person typed two +/// ways. The next is 0.62-0.64 ("Boris Jost" and "Boris", the same +/// again). The highest pair that is plainly two people is 0.43-0.44. +/// - Within **one** person, a random half of their faces against the other +/// half: minimum 0.69, median 0.91 over 72 people; four faces against +/// twenty-two, the shape of the Pascal pair below, reaches 0.7 97% of the +/// time. +/// - The same-name pairs that have faces on both sides are below it: +/// Pascal (22 and 4 confirmed) at 0.57 and PJ (14 and 7) at 0.50. Both +/// sit under the 5th percentile of one person against themselves and +/// above anything two different people reach, so they stay apart and are +/// reported for the user to look at, which is the direction to err in. +pub const SAME_EMBEDDING_COSINE: f32 = 0.7; + +/// Confirmed faces each side needs, in one embedder, before its centroid +/// is compared at all. +/// +/// A centroid of one face is that face. Over the reference library, one +/// face of a person against twenty of somebody else's reached 0.74 in 3,000 +/// draws, past [`SAME_EMBEDDING_COSINE`]; two faces against twenty reached +/// 0.61. No same-name pair on the reference library has a single face. +pub const MIN_FACES_TO_COMPARE: usize = 2; + +/// Boxes overlapping this much in one photograph are one face -- the figure +/// `merge::match_faces` and `faces::record_detections` use for the same +/// question. +const SAME_FACE_IOU: f32 = 0.5; + +/// Why two people of one name were left apart. +#[derive(Debug, Clone, PartialEq)] +pub enum Apart { + /// Their centroids fell short of [`SAME_EMBEDDING_COSINE`]; the lowest + /// over the embedders they share. + Disagree(f32), + /// Too few confirmed faces on one side to compare + /// ([`MIN_FACES_TO_COMPARE`]), while that side holds faces of some + /// kind -- suggestions, typically -- that a merge would carry over. + TooFewFaces, + /// The user confirmed a face as one of them and rejected it as the + /// other. + Contradicted, +} + +/// A same-name person the job did not merge, for the log and for tests. +#[derive(Debug, Clone, PartialEq)] +pub struct KeptApart { + pub name: String, + pub uuid: String, + /// The person it was compared with. + pub survivor: String, + pub why: Apart, +} + +/// What one run did. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DedupReport { + /// Redirect cycles found and broken (two devices merged a pair in + /// opposite directions). + pub cycles_broken: usize, + /// Merged-away people whose faces or rejections this device still held, + /// now moved to the person that outlived them. + pub redirects_followed: usize, + /// Same-name people merged into another, by name. + pub merged: Vec, + /// Same-name people left alone, and why. + pub kept_apart: Vec, + /// Duplicate face rows removed. + pub faces_fused: usize, + /// Duplicate faces left alone because the user confirmed them as two + /// different people. + pub faces_confirmed_apart: usize, +} + +impl DedupReport { + /// Whether the run wrote anything. + pub fn changed(&self) -> bool { + self.cycles_broken + self.redirects_followed + self.merged.len() + self.faces_fused > 0 + } +} + +/// Run the whole job in one transaction, and log what it did. +pub fn run(conn: &Connection) -> Result { + let started = Instant::now(); + let tx = conn.unchecked_transaction()?; + let mut report = DedupReport::default(); + follow_redirects(&tx, &mut report)?; + merge_same_name(&tx, &mut report)?; + fuse_faces(&tx, &mut report)?; + tx.commit()?; + + let apart: Vec = report + .kept_apart + .iter() + .map(|k| match k.why { + Apart::Disagree(cos) => format!("{} ({cos:.2})", k.name), + Apart::TooFewFaces => format!("{} (too few confirmed faces)", k.name), + Apart::Contradicted => format!( + "{} (a face confirmed as one, rejected as the other)", + k.name + ), + }) + .collect(); + log::info!( + "dedup: {} people merged{}, {} same-name kept apart{}; {} redirects followed, \ + {} cycles broken; {} duplicate faces fused, {} confirmed as different people; {:?}", + report.merged.len(), + if report.merged.is_empty() { + String::new() + } else { + format!(" [{}]", report.merged.join(", ")) + }, + report.kept_apart.len(), + if apart.is_empty() { + String::new() + } else { + format!(" [{}]", apart.join(", ")) + }, + report.redirects_followed, + report.cycles_broken, + report.faces_fused, + report.faces_confirmed_apart, + started.elapsed(), + ); + Ok(report) +} + +// ── redirects ───────────────────────────────────────────────────────────── + +/// Break redirect cycles, then move every judgement still held on a +/// merged-away person onto the person at the end of its redirects. +/// +/// The judgements get there two ways: a peer merged the people and this +/// device's own confirmations stayed behind (see the module notes), or a +/// peer on an older build keeps sending the merged-away person's +/// confirmations and rejections, and the merge files them under that +/// person's uuid. Either way they belong to the survivor now, exactly as +/// [`faces::merge_people`] would have filed them. The person's own row is +/// not touched: it is already a redirect, and an edit here would only be +/// sent back. +fn follow_redirects(tx: &Connection, report: &mut DedupReport) -> Result<(), CatalogError> { + let mut next: HashMap = tx + .prepare("SELECT id, merged_into FROM people WHERE merged_into IS NOT NULL")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + if next.is_empty() { + return Ok(()); + } + + // ---- cycles ---------------------------------------------------------- + let mut starts: Vec = next.keys().copied().collect(); + starts.sort_unstable(); + let mut settled: HashSet = HashSet::new(); + let mut uuid_of = tx.prepare_cached("SELECT uuid FROM people WHERE id = ?1")?; + for start in starts { + let mut path: Vec = Vec::new(); + let mut at = start; + loop { + if settled.contains(&at) { + break; + } + if let Some(pos) = path.iter().position(|&p| p == at) { + // Everybody in the cycle points at somebody else in it, so + // nobody is listed. The one both devices would pick lives. + let mut members: Vec<(String, i64)> = Vec::new(); + for &id in &path[pos..] { + members.push((uuid_of.query_row([id], |r| r.get(0))?, id)); + } + members.sort(); + let keep = members[0].1; + tx.execute( + "UPDATE people SET merged_into = NULL, revision = revision + 1, modified = ?2 + WHERE id = ?1", + rusqlite::params![keep, crate::faces::now_secs()], + )?; + next.remove(&keep); + report.cycles_broken += 1; + break; + } + path.push(at); + match next.get(&at) { + Some(&n) => at = n, + None => break, + } + } + settled.extend(path); + } + + // ---- judgements left on a redirect ------------------------------------- + // Driven from the redirects (80-odd on the reference library), each probe + // on `face_person_person`; the rejections are a set small enough to read + // (115 rows) and have no index by person. + let holding: Vec = tx + .prepare( + "SELECT p.id FROM people p + WHERE p.merged_into IS NOT NULL + AND EXISTS (SELECT 1 FROM face_person fp WHERE fp.person_id = p.id) + UNION + SELECT DISTINCT r.person_id FROM face_person_rejected r + JOIN people p ON p.id = r.person_id + WHERE p.merged_into IS NOT NULL", + )? + .query_map([], |r| r.get(0))? + .collect::>()?; + for source in holding { + let mut target = source; + // Bounded as `faces::resolve_person` is; the cycles are gone, so + // this only guards a chain longer than any a user makes. + for _ in 0..32 { + match next.get(&target) { + Some(&n) => target = n, + None => break, + } + } + if target == source || next.contains_key(&target) { + continue; + } + faces::move_judgements(tx, PersonId(target as u64), PersonId(source as u64))?; + report.redirects_followed += 1; + } + Ok(()) +} + +// ── people ──────────────────────────────────────────────────────────────── + +/// A named, listed person, with what they hold. +struct Named { + id: i64, + uuid: String, + name: String, + confirmed: usize, + held: usize, +} + +/// Merge people of one name whose confirmed faces agree. +/// +/// The name is compared as the Identity screen compares it when it offers +/// a merge on rename (`dr_ui::identity`): trimmed and lower-cased, Unicode +/// and all. That is done here rather than in SQL, whose `lower` folds ASCII +/// only; the named people are a few dozen rows. +/// +/// Unnamed people are never merged by this rule, nor anybody the user set +/// aside: an unnamed group has no name to share, and a set-aside one is a +/// decision the user made about that group. +/// +/// Within a group, the survivor is the person with the most confirmed faces +/// (ties to the smaller uuid), and each of the others is compared with it: +/// +/// - a person holding **no faces at all** merges -- there is nothing to +/// compare and nothing to carry, only the name, which is the same. On the +/// reference library that is the second and third Jessie, Claudine, +/// Mathias, Noemi and, on the tablet, the second Ian; +/// - a face **confirmed as one and rejected as the other** keeps them apart: +/// the user has said, about that face, that these are two people; +/// - otherwise both need [`MIN_FACES_TO_COMPARE`] confirmed faces in an +/// embedder they share, and merge when the centroids reach +/// [`SAME_EMBEDDING_COSINE`] in every such embedder. +fn merge_same_name(tx: &Connection, report: &mut DedupReport) -> Result<(), CatalogError> { + let named: Vec<(i64, String, String)> = tx + .prepare( + "SELECT id, uuid, name FROM people + WHERE merged_into IS NULL AND ignored = 0 AND trim(name) <> ''", + )? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))? + .collect::>()?; + let mut groups: HashMap> = HashMap::new(); + for person in named { + let key = person.2.trim().to_lowercase(); + if !key.is_empty() { + groups.entry(key).or_default().push(person); + } + } + groups.retain(|_, g| g.len() > 1); + if groups.is_empty() { + return Ok(()); + } + + let ids = json_ids(groups.values().flatten().map(|p| p.0)); + let counts: HashMap = tx + .prepare( + "SELECT person_id, SUM(confirmed = 1), COUNT(*) FROM face_person + WHERE person_id IN (SELECT value FROM json_each(?1)) + GROUP BY person_id", + )? + .query_map([&ids], |r| { + Ok(( + r.get(0)?, + (r.get::<_, i64>(1)? as usize, r.get::<_, i64>(2)? as usize), + )) + })? + .collect::>()?; + + let mut keys: Vec = groups.keys().cloned().collect(); + keys.sort(); + for key in keys { + let mut members: Vec = groups + .remove(&key) + .unwrap_or_default() + .into_iter() + .map(|(id, uuid, name)| { + let (confirmed, held) = counts.get(&id).copied().unwrap_or((0, 0)); + Named { + id, + uuid, + name, + confirmed, + held, + } + }) + .collect(); + members.sort_by(|a, b| b.confirmed.cmp(&a.confirmed).then(a.uuid.cmp(&b.uuid))); + let survivor = members.remove(0); + let mut centroids: Option = None; + + for other in members { + let apart = if contradicted(tx, survivor.id, other.id)? { + Some(Apart::Contradicted) + } else if other.held == 0 { + None + } else if survivor.confirmed < MIN_FACES_TO_COMPARE + || other.confirmed < MIN_FACES_TO_COMPARE + { + Some(Apart::TooFewFaces) + } else { + let theirs = confirmed_centroids(tx, other.id)?; + let ours = match ¢roids { + Some(c) => c, + None => centroids.insert(confirmed_centroids(tx, survivor.id)?), + }; + match agreement(ours, &theirs) { + None => Some(Apart::TooFewFaces), + Some(cos) if cos >= SAME_EMBEDDING_COSINE => None, + Some(cos) => Some(Apart::Disagree(cos)), + } + }; + match apart { + None => { + faces::merge_people_within( + tx, + PersonId(survivor.id as u64), + PersonId(other.id as u64), + )?; + report.merged.push(other.name); + } + Some(why) => report.kept_apart.push(KeptApart { + name: other.name, + uuid: other.uuid, + survivor: survivor.uuid.clone(), + why, + }), + } + } + } + Ok(()) +} + +/// Whether the user confirmed a face as one of the two and rejected it as +/// the other. Two probes, each from one person's confirmed faces on +/// `face_person_person` into the rejections' primary key. +fn contradicted(tx: &Connection, a: i64, b: i64) -> Result { + let mut stmt = tx.prepare_cached( + "SELECT EXISTS (SELECT 1 FROM face_person fp + JOIN face_person_rejected r + ON r.face_id = fp.face_id AND r.person_id = ?2 + WHERE fp.person_id = ?1 AND fp.confirmed = 1)", + )?; + Ok(stmt.query_row([a, b], |r| r.get::<_, bool>(0))? + || stmt.query_row([b, a], |r| r.get::<_, bool>(0))?) +} + +/// Per embedder: how many confirmed faces, and their unit-length mean. +type Centroids = HashMap)>; + +/// A person's confirmed faces, averaged per embedder (`faces::embedder_of`): +/// two detectors in front of one embedder are one population, two +/// embedders are not comparable at all. +fn confirmed_centroids(tx: &Connection, person: i64) -> Result { + let mut stmt = tx.prepare_cached( + "SELECT f.model_id, f.embedding + FROM face_person fp JOIN faces f ON f.id = fp.face_id + WHERE fp.person_id = ?1 AND fp.confirmed = 1", + )?; + let mut out: Centroids = HashMap::new(); + let rows = stmt.query_map([person], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, Vec>(1)?)) + })?; + for row in rows { + let (model, blob) = row?; + let embedder = faces::embedder_of(&model).to_string(); + let Some(e) = + dr_face::Embedding::from_f16_bytes(dr_face::ModelId::new(embedder.as_str()), &blob) + else { + continue; + }; + let (n, sum) = out + .entry(embedder) + .or_insert_with(|| (0, vec![0.0; dr_face::EMBEDDING_DIM])); + *n += 1; + sum.iter_mut().zip(e.v.iter()).for_each(|(s, x)| *s += x); + } + for (_, sum) in out.values_mut() { + let norm = sum.iter().map(|x| x * x).sum::().sqrt(); + if norm > 0.0 { + sum.iter_mut().for_each(|x| *x /= norm); + } + } + Ok(out) +} + +/// The lowest centroid cosine over the embedders both sides have enough +/// faces in, or `None` where there is no such embedder. +fn agreement(a: &Centroids, b: &Centroids) -> Option { + a.iter() + .filter(|(_, (n, _))| *n >= MIN_FACES_TO_COMPARE) + .filter_map(|(embedder, (_, ca))| { + let (n, cb) = b.get(embedder)?; + (*n >= MIN_FACES_TO_COMPARE).then(|| ca.iter().zip(cb).map(|(x, y)| x * y).sum::()) + }) + .reduce(f32::min) +} + +// ── faces ───────────────────────────────────────────────────────────────── + +/// A face's box and pipeline, as the covering index holds them. +struct Boxed { + id: i64, + model: String, + rect: (f32, f32, f32, f32), +} + +/// `(person, probability, confirmed)`. +type Assignment = (i64, f64, bool); + +/// Remove the second row of a face held twice: one photograph, one embedder, +/// boxes at [`SAME_FACE_IOU`] and vectors at [`SAME_EMBEDDING_COSINE`]. +/// +/// The face kept is the stronger detector's (`FaceDetector::outranks`; an +/// id no detector claims ranks as an equal), then the one with a +/// confirmation, then the older row. It takes the pair's assignment -- a +/// confirmation over a suggestion, its own where both are alike -- and the +/// union of their rejections, and the same two rules `faces::confirm` and +/// `faces::reject` apply settle a clash between them. A pair the user +/// confirmed as two *different* people is left as it is and counted: that +/// is a judgement about the photograph this job has no business +/// overruling, whatever the vectors say. +/// +/// The boxes come out of `faces_box` without opening a face row -- a row is +/// eight kilobytes -- and on the reference library that scan is the whole +/// of a pass with nothing to do: 7 ms for 19,000 faces. +fn fuse_faces(tx: &Connection, report: &mut DedupReport) -> Result<(), CatalogError> { + crate::merge::ensure_face_box_index(tx); + + let mut pairs: Vec<(i64, i64)> = Vec::new(); + let mut model_of: HashMap = HashMap::new(); + { + let mut stmt = + tx.prepare("SELECT id, image_id, model_id, x, y, w, h FROM faces ORDER BY image_id")?; + let mut rows = stmt.query([])?; + let mut image: Option = None; + let mut on_image: Vec = Vec::new(); + let mut flush = |on_image: &mut Vec| { + for (i, a) in on_image.iter().enumerate() { + for b in &on_image[i + 1..] { + if faces::embedder_of(&a.model) == faces::embedder_of(&b.model) + && crate::merge::iou(a.rect, b.rect) >= SAME_FACE_IOU + { + pairs.push((a.id.min(b.id), a.id.max(b.id))); + model_of.insert(a.id, a.model.clone()); + model_of.insert(b.id, b.model.clone()); + } + } + } + on_image.clear(); + }; + while let Some(r) = rows.next()? { + let at: i64 = r.get(1)?; + if image != Some(at) { + flush(&mut on_image); + image = Some(at); + } + on_image.push(Boxed { + id: r.get(0)?, + model: r.get(2)?, + rect: ( + r.get::<_, f64>(3)? as f32, + r.get::<_, f64>(4)? as f32, + r.get::<_, f64>(5)? as f32, + r.get::<_, f64>(6)? as f32, + ), + }); + } + flush(&mut on_image); + } + if pairs.is_empty() { + return Ok(()); + } + pairs.sort_unstable(); + + let ids = json_ids(model_of.keys().copied()); + let vectors: HashMap = { + let mut stmt = tx.prepare( + "SELECT f.id, f.embedding FROM json_each(?1) j JOIN faces f ON f.id = j.value", + )?; + let rows: Vec<(i64, Vec)> = stmt + .query_map([&ids], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + rows.into_iter() + .filter_map(|(id, blob)| { + let embedder = faces::embedder_of(&model_of[&id]); + let e = dr_face::Embedding::from_f16_bytes(dr_face::ModelId::new(embedder), &blob)?; + Some((id, e)) + }) + .collect() + }; + let mut assigned: HashMap = tx + .prepare( + "SELECT fp.face_id, fp.person_id, fp.probability, fp.confirmed + FROM json_each(?1) j JOIN face_person fp ON fp.face_id = j.value", + )? + .query_map([&ids], |r| { + Ok((r.get(0)?, (r.get(1)?, r.get(2)?, r.get(3)?))) + })? + .collect::>()?; + + let rank = |id: i64| FaceDetector::for_model_id(&model_of[&id]); + let mut gone: HashSet = HashSet::new(); + for (a, b) in pairs { + if gone.contains(&a) || gone.contains(&b) { + continue; + } + let same = match (vectors.get(&a), vectors.get(&b)) { + (Some(va), Some(vb)) => va.cosine(vb).is_some_and(|c| c >= SAME_EMBEDDING_COSINE), + _ => false, + }; + if !same { + continue; + } + let (ha, hb) = (assigned.get(&a).copied(), assigned.get(&b).copied()); + if let (Some(x), Some(y)) = (ha, hb) { + if x.2 && y.2 && x.0 != y.0 { + report.faces_confirmed_apart += 1; + continue; + } + } + let confirmed = |h: Option| h.is_some_and(|h| h.2); + let a_wins = match (rank(a), rank(b)) { + (Some(ra), Some(rb)) if ra.outranks(rb) => true, + (Some(ra), Some(rb)) if rb.outranks(ra) => false, + _ if confirmed(ha) != confirmed(hb) => confirmed(ha), + // `pairs` holds the smaller id first. + _ => true, + }; + let (keep, drop, held, other) = if a_wins { + (a, b, ha, hb) + } else { + (b, a, hb, ha) + }; + let wanted = match (held, other) { + (Some(k), Some(d)) if !k.2 && d.2 => Some(d), + (Some(k), _) => Some(k), + (None, d) => d, + }; + + tx.execute( + "INSERT OR IGNORE INTO face_person_rejected (face_id, person_id) + SELECT ?1, person_id FROM face_person_rejected WHERE face_id = ?2", + [keep, drop], + )?; + tx.execute( + "DELETE FROM face_person_rejected WHERE face_id = ?1", + [drop], + )?; + tx.execute("DELETE FROM face_person WHERE face_id = ?1", [drop])?; + tx.execute("DELETE FROM faces WHERE id = ?1", [drop])?; + if let Some((person, probability, confirmed)) = wanted { + if wanted != held { + tx.execute( + "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", + rusqlite::params![keep, person, probability, confirmed], + )?; + } + if confirmed { + tx.execute( + "DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2", + [keep, person], + )?; + assigned.insert(keep, (person, probability, confirmed)); + } else if tx.execute( + "DELETE FROM face_person + WHERE face_id = ?1 AND person_id = ?2 + AND EXISTS (SELECT 1 FROM face_person_rejected + WHERE face_id = ?1 AND person_id = ?2)", + [keep, person], + )? > 0 + { + assigned.remove(&keep); + } else { + assigned.insert(keep, (person, probability, confirmed)); + } + } + assigned.remove(&drop); + gone.insert(drop); + report.faces_fused += 1; + } + Ok(()) +} + +/// Row ids as a JSON array, for `json_each` -- one statement for a set of +/// ids rather than one per id. +fn json_ids(ids: impl Iterator) -> String { + let ids: Vec = ids.map(|id| id.to_string()).collect(); + format!("[{}]", ids.join(",")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema; + use rusqlite::OptionalExtension; + + fn db() -> Connection { + let c = Connection::open_in_memory().unwrap(); + schema::configure(&c).unwrap(); + schema::migrate(&c).unwrap(); + c + } + + /// A unit vector, 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 + } + + /// Photograph `shot` of the person `who`: their vector plus a little of + /// the photograph's own. Two shots of one person sit near 0.9. + fn shot(who: u32, shot: u32) -> Vec { + let (a, b) = (vector(who), vector(10_000 + shot)); + let mut v: Vec = a.iter().zip(&b).map(|(x, y)| x + 0.3 * y).collect(); + let norm = v.iter().map(|x| x * x).sum::().sqrt(); + v.iter_mut().for_each(|x| *x /= norm); + v + } + + fn image(c: &Connection, id: i64) { + c.execute( + "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", + rusqlite::params![id, format!("img{id}.CR3")], + ) + .unwrap(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + rusqlite::params![id, 5000 + id], + ) + .unwrap(); + } + + /// A face on `image` at `x`, embedded as `v` under `model`. + fn face(c: &Connection, id: i64, image: i64, x: f64, model: &str, v: &[f32]) -> i64 { + let e = dr_face::Embedding { + model: dr_face::ModelId::new("w600k_mbf"), + v: Box::new(v.try_into().unwrap()), + }; + c.execute( + "INSERT INTO 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, ?4, 150.0, ?5, 0)", + rusqlite::params![id, image, x, e.to_f16_bytes(), model], + ) + .unwrap(); + id + } + + fn person(c: &Connection, id: i64, uuid: &str, name: &str) -> i64 { + c.execute( + "INSERT INTO people(id, uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 0, 1, 1)", + rusqlite::params![id, uuid, name], + ) + .unwrap(); + id + } + + fn assign(c: &Connection, face: i64, person: i64, confirmed: bool) { + c.execute( + "INSERT INTO face_person(face_id, person_id, probability, confirmed) + VALUES (?1, ?2, 0.9, ?3) + ON CONFLICT(face_id) DO UPDATE SET person_id = excluded.person_id, + confirmed = excluded.confirmed", + rusqlite::params![face, person, confirmed], + ) + .unwrap(); + } + + fn reject(c: &Connection, face: i64, person: i64) { + c.execute( + "INSERT INTO face_person_rejected(face_id, person_id) VALUES (?1, ?2)", + [face, person], + ) + .unwrap(); + } + + /// `n` photographs of person `who`, each with one face, confirmed to + /// `person`; image and face ids start at `first`. + fn photographed(c: &Connection, first: i64, n: i64, who: u32, person: i64) -> Vec { + (first..first + n) + .map(|id| { + image(c, id); + let f = face(c, id, id, 0.3, "w600k_mbf", &shot(who, id as u32)); + assign(c, f, person, true); + f + }) + .collect() + } + + /// `(face, person uuid, confirmed)` for a face, or None. + fn holder(c: &Connection, face: i64) -> Option<(String, bool)> { + c.query_row( + "SELECT p.uuid, fp.confirmed FROM face_person fp + JOIN people p ON p.id = fp.person_id WHERE fp.face_id = ?1", + [face], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .unwrap() + } + + fn rejections(c: &Connection) -> Vec<(i64, String)> { + c.prepare( + "SELECT r.face_id, p.uuid FROM face_person_rejected r + JOIN people p ON p.id = r.person_id ORDER BY 1, 2", + ) + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .collect::>() + .unwrap() + } + + /// Who the People screen lists, as `(uuid, name)`. + fn listed(c: &Connection) -> Vec<(String, String)> { + let mut v: Vec<(String, String)> = faces::people(c) + .unwrap() + .into_iter() + .map(|p| (p.uuid, p.name)) + .collect(); + v.sort(); + v + } + + // ── people ──────────────────────────────────────────────────────────── + + /// The case the job is for: one person typed on each device. Every + /// judgement on the smaller half lands on the survivor, and the name + /// survives as the survivor spelled it. + #[test] + fn same_name_people_whose_faces_agree_merge_with_their_judgements() { + let c = db(); + let ian = person(&c, 1, "u-ian-a", "Ian"); + let ian2 = person(&c, 2, "u-ian-b", " ian "); + let bob = person(&c, 3, "u-bob", "Bob"); + let big = photographed(&c, 1, 3, 7, ian); + let small = photographed(&c, 10, 2, 7, ian2); + // A suggestion and a rejection on the smaller half. + image(&c, 20); + let suggested = face(&c, 20, 20, 0.3, "w600k_mbf", &shot(7, 20)); + assign(&c, suggested, ian2, false); + image(&c, 21); + let not_ian = face(&c, 21, 21, 0.3, "w600k_mbf", &shot(99, 21)); + reject(&c, not_ian, ian2); + photographed(&c, 30, 2, 50, bob); + + let report = run(&c).unwrap(); + assert_eq!(report.merged, [" ian "]); + assert!(report.kept_apart.is_empty()); + for f in big.iter().chain(&small) { + assert_eq!(holder(&c, *f), Some(("u-ian-a".into(), true))); + } + assert_eq!(holder(&c, suggested), Some(("u-ian-a".into(), false))); + assert_eq!(rejections(&c), [(not_ian, "u-ian-a".to_string())]); + assert_eq!( + listed(&c), + [ + ("u-bob".into(), "Bob".into()), + ("u-ian-a".into(), "Ian".into()) + ] + ); + assert_eq!( + faces::resolve_person(&c, PersonId(ian2 as u64)).unwrap(), + PersonId(ian as u64) + ); + } + + /// Pascal and PJ on the reference library: one name, two faces that do + /// not agree (0.57 and 0.50). They may be two people; the user decides. + #[test] + fn same_name_people_whose_faces_disagree_stay_apart() { + let c = db(); + let a = person(&c, 1, "u-a", "Pascal"); + let b = person(&c, 2, "u-b", "Pascal"); + photographed(&c, 1, 4, 7, a); + photographed(&c, 10, 2, 8, b); + + let report = run(&c).unwrap(); + assert!(report.merged.is_empty()); + assert_eq!(report.kept_apart.len(), 1); + let kept = &report.kept_apart[0]; + assert_eq!((kept.uuid.as_str(), kept.survivor.as_str()), ("u-b", "u-a")); + assert!(matches!(kept.why, Apart::Disagree(cos) if cos < SAME_EMBEDDING_COSINE)); + assert_eq!(listed(&c).len(), 2); + } + + /// "Michelle Casanonve" and "Michelle Casanova" are one person at 0.70 + /// on the reference library, and still not this job's to merge: the + /// rule is the name first. + #[test] + fn different_names_never_merge_however_alike() { + let c = db(); + let a = person(&c, 1, "u-a", "Michelle Casanonve"); + let b = person(&c, 2, "u-b", "Michelle Casanova"); + photographed(&c, 1, 3, 7, a); + photographed(&c, 10, 3, 7, b); + + let report = run(&c).unwrap(); + assert!(!report.changed()); + assert_eq!(listed(&c).len(), 2); + } + + #[test] + fn unnamed_people_are_never_merged() { + let c = db(); + let a = person(&c, 1, "u-a", ""); + let b = person(&c, 2, "u-b", " "); + photographed(&c, 1, 3, 7, a); + photographed(&c, 10, 3, 7, b); + + let report = run(&c).unwrap(); + assert!(!report.changed()); + assert_eq!(listed(&c).len(), 2); + } + + /// The second and third Jessie on the reference library hold nothing: + /// no faces to compare and none to carry. The person holding only + /// suggestions (the desktop's second Ian) is another matter -- merging + /// would carry them, and there is no confirmation to vouch for them. + #[test] + fn an_empty_namesake_merges_and_one_with_only_suggestions_does_not() { + let c = db(); + let jessie = person(&c, 1, "u-j1", "Jessie"); + person(&c, 2, "u-j2", "Jessie"); + person(&c, 3, "u-j3", "jessie"); + photographed(&c, 1, 3, 7, jessie); + let ian = person(&c, 4, "u-i1", "Ian"); + let ian2 = person(&c, 5, "u-i2", "Ian"); + photographed(&c, 10, 3, 8, ian); + image(&c, 20); + let f = face(&c, 20, 20, 0.3, "w600k_mbf", &shot(9, 20)); + assign(&c, f, ian2, false); + + let report = run(&c).unwrap(); + assert_eq!(report.merged, ["Jessie", "jessie"]); + assert_eq!(report.kept_apart.len(), 1); + assert_eq!(report.kept_apart[0].uuid, "u-i2"); + assert_eq!(report.kept_apart[0].why, Apart::TooFewFaces); + assert_eq!(holder(&c, f), Some(("u-i2".into(), false))); + } + + /// The user confirmed a face as one and said it is not the other: that + /// is a statement that these are two people, and it outranks a cosine. + #[test] + fn a_face_confirmed_as_one_and_rejected_as_the_other_keeps_them_apart() { + let c = db(); + let a = person(&c, 1, "u-a", "Anna"); + let b = person(&c, 2, "u-b", "Anna"); + let faces_a = photographed(&c, 1, 3, 7, a); + photographed(&c, 10, 2, 7, b); + reject(&c, faces_a[0], b); + + let report = run(&c).unwrap(); + assert!(report.merged.is_empty()); + assert_eq!(report.kept_apart[0].why, Apart::Contradicted); + } + + #[test] + fn a_person_set_aside_is_not_merged_by_name() { + let c = db(); + let a = person(&c, 1, "u-a", "Anna"); + let b = person(&c, 2, "u-b", "Anna"); + photographed(&c, 1, 3, 7, a); + photographed(&c, 10, 2, 7, b); + c.execute("UPDATE people SET ignored = 1 WHERE id = ?1", [b]) + .unwrap(); + + assert!(!run(&c).unwrap().changed()); + } + + // ── redirects ───────────────────────────────────────────────────────── + + /// What an older peer leaves behind after it learns of a merge: its own + /// confirmation still on the person merged away. + #[test] + fn a_confirmation_left_on_a_redirect_follows_it() { + let c = db(); + let anna = person(&c, 1, "u-a", "Anna"); + let annie = person(&c, 2, "u-b", "Annie"); + let f = photographed(&c, 1, 1, 7, annie)[0]; + image(&c, 5); + let g = face(&c, 5, 5, 0.3, "w600k_mbf", &shot(9, 5)); + reject(&c, g, annie); + c.execute( + "UPDATE people SET merged_into = ?1 WHERE id = ?2", + [anna, annie], + ) + .unwrap(); + + let report = run(&c).unwrap(); + assert_eq!(report.redirects_followed, 1); + assert_eq!(holder(&c, f), Some(("u-a".into(), true))); + assert_eq!(rejections(&c), [(g, "u-a".to_string())]); + assert!(!run(&c).unwrap().changed()); + } + + /// Two devices that each merged the pair the other way round sync into + /// two redirects pointing at each other, and nobody is listed. Both + /// devices keep the smaller uuid. + #[test] + fn a_redirect_cycle_is_broken_at_the_smaller_uuid() { + let c = db(); + let x = person(&c, 1, "u-b", "Anna"); + let y = person(&c, 2, "u-a", "Anna"); + let fx = photographed(&c, 1, 2, 7, x); + let fy = photographed(&c, 10, 2, 7, y); + c.execute("UPDATE people SET merged_into = ?1 WHERE id = ?2", [y, x]) + .unwrap(); + c.execute("UPDATE people SET merged_into = ?1 WHERE id = ?2", [x, y]) + .unwrap(); + assert!(listed(&c).is_empty()); + + let report = run(&c).unwrap(); + assert_eq!(report.cycles_broken, 1); + assert_eq!(listed(&c), [("u-a".into(), "Anna".into())]); + for f in fx.iter().chain(&fy) { + assert_eq!(holder(&c, *f), Some(("u-a".into(), true))); + } + } + + // ── faces ───────────────────────────────────────────────────────────── + + /// One face held twice: the weaker detector's copy goes, and its + /// confirmation and rejection move onto the copy that stays. + #[test] + fn a_face_held_twice_is_fused_onto_the_stronger_detector() { + let c = db(); + let anna = person(&c, 1, "u-a", "Anna"); + let bob = person(&c, 2, "u-b", "Bob"); + image(&c, 1); + let weak = face(&c, 1, 1, 0.30, "w600k_mbf", &shot(7, 1)); + let strong = face(&c, 2, 1, 0.31, "scrfd_2.5g+w600k_mbf", &shot(7, 2)); + assign(&c, weak, anna, true); + reject(&c, weak, bob); + assign(&c, strong, bob, false); + + let report = run(&c).unwrap(); + assert_eq!(report.faces_fused, 1); + let left: Vec = c + .prepare("SELECT id FROM faces") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(left, [strong]); + assert_eq!(holder(&c, strong), Some(("u-a".into(), true))); + assert_eq!(rejections(&c), [(strong, "u-b".to_string())]); + } + + /// Between equal detectors the confirmed copy stays, then the older row. + #[test] + fn between_equal_detectors_the_confirmed_copy_stays() { + let c = db(); + let anna = person(&c, 1, "u-a", "Anna"); + image(&c, 1); + let first = face(&c, 1, 1, 0.30, "w600k_mbf", &shot(7, 1)); + let second = face(&c, 2, 1, 0.31, "w600k_mbf", &shot(7, 2)); + assign(&c, second, anna, true); + + run(&c).unwrap(); + assert_eq!(holder(&c, second), Some(("u-a".into(), true))); + let n: i64 = c + .query_row("SELECT COUNT(*) FROM faces WHERE id = ?1", [first], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn a_face_pair_confirmed_as_two_people_is_left_alone() { + let c = db(); + let anna = person(&c, 1, "u-a", "Anna"); + let bob = person(&c, 2, "u-b", "Bob"); + image(&c, 1); + let a = face(&c, 1, 1, 0.30, "w600k_mbf", &shot(7, 1)); + let b = face(&c, 2, 1, 0.31, "w600k_mbf", &shot(7, 2)); + assign(&c, a, anna, true); + assign(&c, b, bob, true); + + let report = run(&c).unwrap(); + assert_eq!((report.faces_fused, report.faces_confirmed_apart), (0, 1)); + assert_eq!(holder(&c, a), Some(("u-a".into(), true))); + assert_eq!(holder(&c, b), Some(("u-b".into(), true))); + } + + /// Two people cheek to cheek: boxes that overlap, vectors that do not. + #[test] + fn overlapping_boxes_of_two_people_are_not_fused() { + let c = db(); + image(&c, 1); + face(&c, 1, 1, 0.30, "w600k_mbf", &shot(7, 1)); + face(&c, 2, 1, 0.31, "w600k_mbf", &shot(8, 2)); + assert_eq!(run(&c).unwrap().faces_fused, 0); + } + + #[test] + fn the_job_is_idempotent() { + let c = db(); + let a = person(&c, 1, "u-a", "Anna"); + let b = person(&c, 2, "u-b", "anna"); + person(&c, 3, "u-c", "Anna"); + photographed(&c, 1, 3, 7, a); + photographed(&c, 10, 2, 7, b); + image(&c, 20); + face(&c, 20, 20, 0.30, "w600k_mbf", &shot(9, 1)); + face(&c, 21, 20, 0.31, "w600k_mbf", &shot(9, 2)); + + let first = run(&c).unwrap(); + assert!(first.changed()); + let listed_once = listed(&c); + let second = run(&c).unwrap(); + assert!(!second.changed(), "{second:?}"); + assert_eq!(listed(&c), listed_once); + } + + // ── across two devices ──────────────────────────────────────────────── + + fn open(path: &std::path::Path) -> Connection { + let c = Connection::open(path).unwrap(); + schema::configure(&c).unwrap(); + schema::migrate(&c).unwrap(); + c + } + + /// What the release before this job does on a sync: the same merge, + /// and nothing after it. + fn merge_without_dedup(c: &Connection, remote: &std::path::Path) { + c.execute( + "ATTACH DATABASE ?1 AS remote_cat", + [remote.to_string_lossy().as_ref()], + ) + .unwrap(); + crate::merge::merge_all(c).unwrap(); + c.execute("DETACH DATABASE remote_cat", []).unwrap(); + } + + /// The library as both devices hold it after the names were typed on + /// each: two Ians whose faces agree, and an empty second Jessie. + fn library(c: &Connection) { + let ian = person(c, 1, "u-ian-1", "Ian"); + let ian2 = person(c, 2, "u-ian-2", "Ian"); + let jessie = person(c, 3, "u-jes-1", "Jessie"); + person(c, 4, "u-jes-2", "Jessie"); + photographed(c, 1, 3, 7, ian); + photographed(c, 10, 2, 7, ian2); + photographed(c, 20, 2, 8, jessie); + } + + /// The acceptance test of #78: a merge made on one device survives a + /// sync round trip with a peer that does not run the job -- the peer + /// hides the duplicates and never sends them back -- and the two + /// converge and stay converged. + #[test] + fn a_merge_survives_a_round_trip_through_a_peer_without_the_job() { + let dir = std::env::temp_dir().join(format!( + "dr-dedup-people-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (a_path, b_path) = (dir.join("a.sqlite"), dir.join("b.sqlite")); + let (a, b) = (open(&a_path), open(&b_path)); + library(&a); + library(&b); + let expected = vec![ + ("u-ian-1".to_string(), "Ian".to_string()), + ("u-jes-1".to_string(), "Jessie".to_string()), + ]; + + // Device A runs the job. + let report = run(&a).unwrap(); + assert_eq!(report.merged, ["Ian", "Jessie"]); + assert_eq!(listed(&a), expected); + + // B syncs with the previous release's code. It takes the redirects + // and lists what A lists. Its own confirmations of the second Ian + // stay where it put them -- a merge never overwrites a local + // confirmation -- on a person it no longer lists. + merge_without_dedup(&b, &a_path); + assert_eq!(listed(&b), expected); + assert_eq!(holder(&b, 10), Some(("u-ian-2".into(), true))); + + // A syncs back, with the job. Nothing comes back to life. + crate::sync::merge_remote(&a, &b_path).unwrap(); + assert_eq!(listed(&a), expected); + for f in [1, 2, 3, 10, 11] { + assert_eq!(holder(&a, f), Some(("u-ian-1".into(), true))); + } + + // Another round changes nothing on either side. + merge_without_dedup(&b, &a_path); + crate::sync::merge_remote(&a, &b_path).unwrap(); + assert_eq!(listed(&a), expected); + assert_eq!(listed(&b), expected); + assert!(!run(&a).unwrap().changed()); + + // B upgrades: its stranded confirmations follow the redirect, and it + // merges nothing A has not. + let upgraded = run(&b).unwrap(); + assert!(upgraded.merged.is_empty(), "{upgraded:?}"); + assert_eq!(upgraded.redirects_followed, 1); + for f in [1, 2, 3, 10, 11] { + assert_eq!(holder(&b, f), Some(("u-ian-1".into(), true))); + } + crate::sync::merge_remote(&a, &b_path).unwrap(); + crate::sync::merge_remote(&b, &a_path).unwrap(); + assert_eq!(listed(&a), expected); + assert_eq!(listed(&b), expected); + + drop((a, b)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/core/dr-catalog/src/faces.rs b/core/dr-catalog/src/faces.rs index 98d0218..ab57d2c 100644 --- a/core/dr-catalog/src/faces.rs +++ b/core/dr-catalog/src/faces.rs @@ -1064,6 +1064,28 @@ pub(crate) fn merge_people_within( if target == source { return Ok(0); } + let moved = move_judgements(tx, target, source)?; + tx.execute( + "UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3 + WHERE id = ?2", + rusqlite::params![target.0 as i64, source.0 as i64, now_secs()], + )?; + Ok(moved) +} + +/// The half of [`merge_people_within`] that moves faces and rejections, +/// without touching either person's row. +/// +/// Also what follows a redirect that arrived by sync +/// (`crate::dedup_people`): the other device merged the people, and this +/// one still holds judgements on the person merged away. Bumping the +/// person's revision there would be an edit of this device's own, sent back +/// on every pass, so the row is left as the merge wrote it. +pub(crate) fn move_judgements( + tx: &Connection, + target: PersonId, + source: PersonId, +) -> Result { let (t, s) = (target.0 as i64, source.0 as i64); // A face already assigned to the target must not gain a second row — // `face_person` is keyed by face. Where both hold the same face, the @@ -1097,11 +1119,6 @@ pub(crate) fn merge_people_within( AND face_id IN (SELECT face_id FROM face_person_rejected WHERE person_id = ?1)", [t], )?; - tx.execute( - "UPDATE people SET merged_into = ?1, revision = revision + 1, modified = ?3 - WHERE id = ?2", - rusqlite::params![t, s, now_secs()], - )?; Ok(moved as u64) } diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 51e4f08..09e6442 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -42,6 +42,7 @@ pub mod bursts; pub mod cache; pub mod collections; pub mod dedup; +pub mod dedup_people; pub mod duplicates; pub mod error; pub mod face_shard; diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index 315576e..ca97e97 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -1572,7 +1572,7 @@ fn pair_by_embedding( /// 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. -fn ensure_face_box_index(tx: &Connection) { +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);", ) { @@ -1581,7 +1581,7 @@ fn ensure_face_box_index(tx: &Connection) { } /// Intersection over union of two `(x, y, w, h)` boxes. -fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { +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); diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 2d955e5..2289356 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 462 | -| TRACES tags found | 1870 | +| Source files scanned | 464 | +| TRACES tags found | 1871 | | Requirements defined | 192 | | Requirements deferred (post-v1) | 24 | | Requirements covered | 161 | @@ -37,7 +37,7 @@ _None._ | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:108`](../../core/dr-catalog/src/walk.rs#L108), [`core/dr-catalog/src/walk.rs:161`](../../core/dr-catalog/src/walk.rs#L161), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`core/dr-types/src/lib.rs:203`](../../core/dr-types/src/lib.rs#L203), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1) | | FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:377`](../../core/dr-types/src/settings.rs#L377), [`platform/dr-plat/src/storage.rs:287`](../../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:346`](../../ui/dr-ui/src/import.rs#L346), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1831`](../../ui/dr-ui/src/lib.rs#L1831), [`ui/dr-ui/ui/import.slint:6`](../../ui/dr-ui/ui/import.slint#L6), [`ui/dr-ui/ui/library.slint:1348`](../../ui/dr-ui/ui/library.slint#L1348), [`ui/dr-ui/ui/library.slint:1402`](../../ui/dr-ui/ui/library.slint#L1402), [`ui/dr-ui/ui/library.slint:1660`](../../ui/dr-ui/ui/library.slint#L1660) | | FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1831`](../../ui/dr-ui/src/lib.rs#L1831), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/thumbnails_gen.rs:524`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L524), [`ui/dr-ui/ui/import.slint:6`](../../ui/dr-ui/ui/import.slint#L6) | -| FR-CAT-11a | [`core/dr-catalog/src/duplicates.rs:1`](../../core/dr-catalog/src/duplicates.rs#L1), [`core/dr-catalog/src/error.rs:110`](../../core/dr-catalog/src/error.rs#L110), [`core/dr-catalog/src/faces.rs:1645`](../../core/dr-catalog/src/faces.rs#L1645), [`core/dr-catalog/src/faces.rs:1663`](../../core/dr-catalog/src/faces.rs#L1663), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:79`](../../ui/dr-ui/src/duplicates_ui.rs#L79), [`ui/dr-ui/src/lib.rs:1861`](../../ui/dr-ui/src/lib.rs#L1861), [`ui/dr-ui/src/lib.rs:3437`](../../ui/dr-ui/src/lib.rs#L3437), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/ui/app.slint:83`](../../ui/dr-ui/ui/app.slint#L83), [`ui/dr-ui/ui/collections.slint:897`](../../ui/dr-ui/ui/collections.slint#L897), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1), [`ui/dr-ui/ui/settings.slint:108`](../../ui/dr-ui/ui/settings.slint#L108), [`ui/dr-ui/ui/settings.slint:512`](../../ui/dr-ui/ui/settings.slint#L512) | +| FR-CAT-11a | [`core/dr-catalog/src/duplicates.rs:1`](../../core/dr-catalog/src/duplicates.rs#L1), [`core/dr-catalog/src/error.rs:110`](../../core/dr-catalog/src/error.rs#L110), [`core/dr-catalog/src/faces.rs:1662`](../../core/dr-catalog/src/faces.rs#L1662), [`core/dr-catalog/src/faces.rs:1680`](../../core/dr-catalog/src/faces.rs#L1680), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:79`](../../ui/dr-ui/src/duplicates_ui.rs#L79), [`ui/dr-ui/src/lib.rs:1861`](../../ui/dr-ui/src/lib.rs#L1861), [`ui/dr-ui/src/lib.rs:3437`](../../ui/dr-ui/src/lib.rs#L3437), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/ui/app.slint:83`](../../ui/dr-ui/ui/app.slint#L83), [`ui/dr-ui/ui/collections.slint:897`](../../ui/dr-ui/ui/collections.slint#L897), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1), [`ui/dr-ui/ui/settings.slint:108`](../../ui/dr-ui/ui/settings.slint#L108), [`ui/dr-ui/ui/settings.slint:512`](../../ui/dr-ui/ui/settings.slint#L512) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:140`](../../core/dr-pipeline/src/sidecar.rs#L140) | | FR-CAT-13 | [`core/dr-catalog/src/rating.rs:276`](../../core/dr-catalog/src/rating.rs#L276), [`core/dr-catalog/src/schema.rs:866`](../../core/dr-catalog/src/schema.rs#L866), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library/scan.rs:335`](../../ui/dr-ui/src/library/scan.rs#L335), [`ui/dr-ui/src/library/xmp.rs:132`](../../ui/dr-ui/src/library/xmp.rs#L132), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:317`](../../ui/dr-ui/src/library_ui/controller.rs#L317), [`ui/dr-ui/src/library_ui/controller.rs:408`](../../ui/dr-ui/src/library_ui/controller.rs#L408), [`ui/dr-ui/src/library_ui/controller.rs:556`](../../ui/dr-ui/src/library_ui/controller.rs#L556), [`ui/dr-ui/src/library_ui/grid.rs:206`](../../ui/dr-ui/src/library_ui/grid.rs#L206), [`ui/dr-ui/src/library_ui/open.rs:438`](../../ui/dr-ui/src/library_ui/open.rs#L438), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:118`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L118), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:664`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L664), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:693`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L693), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:749`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L749), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:848`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L848), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:659`](../../ui/dr-ui/ui/app.slint#L659), [`ui/dr-ui/ui/settings.slint:210`](../../ui/dr-ui/ui/settings.slint#L210), [`ui/dr-ui/ui/settings.slint:989`](../../ui/dr-ui/ui/settings.slint#L989) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1240`](../../core/dr-catalog/src/schema.rs#L1240), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:549`](../../core/dr-sync-nextcloud/src/lib.rs#L549), [`core/dr-sync/src/lib.rs:155`](../../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui/trash.rs:84`](../../ui/dr-ui/src/collections_ui/trash.rs#L84), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:819`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L819), [`ui/dr-ui/src/duplicates.rs:1`](../../ui/dr-ui/src/duplicates.rs#L1), [`ui/dr-ui/src/duplicates_ui.rs:1`](../../ui/dr-ui/src/duplicates_ui.rs#L1), [`ui/dr-ui/src/library/cells.rs:150`](../../ui/dr-ui/src/library/cells.rs#L150), [`ui/dr-ui/src/library/cells.rs:184`](../../ui/dr-ui/src/library/cells.rs#L184), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/filters.rs:29`](../../ui/dr-ui/src/library/filters.rs#L29), [`ui/dr-ui/src/library/filters.rs:59`](../../ui/dr-ui/src/library/filters.rs#L59), [`ui/dr-ui/src/library_ui/controller.rs:1003`](../../ui/dr-ui/src/library_ui/controller.rs#L1003), [`ui/dr-ui/src/library_ui/controller.rs:253`](../../ui/dr-ui/src/library_ui/controller.rs#L253), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:1283`](../../ui/dr-ui/ui/collections.slint#L1283), [`ui/dr-ui/ui/collections.slint:802`](../../ui/dr-ui/ui/collections.slint#L802), [`ui/dr-ui/ui/duplicates.slint:1`](../../ui/dr-ui/ui/duplicates.slint#L1) | @@ -47,13 +47,13 @@ _None._ | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:594`](../../core/dr-catalog/src/schema.rs#L594), [`ui/dr-ui/src/library/filters.rs:39`](../../ui/dr-ui/src/library/filters.rs#L39), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488) | | FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/rating.rs:396`](../../core/dr-catalog/src/rating.rs#L396), [`core/dr-catalog/src/rating.rs:411`](../../core/dr-catalog/src/rating.rs#L411), [`core/dr-catalog/src/rating.rs:422`](../../core/dr-catalog/src/rating.rs#L422), [`core/dr-catalog/src/rating.rs:449`](../../core/dr-catalog/src/rating.rs#L449), [`core/dr-catalog/src/rating.rs:52`](../../core/dr-catalog/src/rating.rs#L52), [`core/dr-catalog/src/rating.rs:827`](../../core/dr-catalog/src/rating.rs#L827), [`core/dr-catalog/src/rating.rs:851`](../../core/dr-catalog/src/rating.rs#L851), [`core/dr-catalog/src/rating.rs:870`](../../core/dr-catalog/src/rating.rs#L870), [`core/dr-catalog/src/schema.rs:1155`](../../core/dr-catalog/src/schema.rs#L1155), [`core/dr-catalog/src/schema.rs:1742`](../../core/dr-catalog/src/schema.rs#L1742), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:313`](../../core/dr-decode/src/lib.rs#L313), [`core/dr-decode/src/lib.rs:323`](../../core/dr-decode/src/lib.rs#L323), [`core/dr-decode/src/lib.rs:450`](../../core/dr-decode/src/lib.rs#L450), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:1093`](../../core/dr-pipeline/src/sidecar.rs#L1093), [`core/dr-pipeline/src/sidecar.rs:174`](../../core/dr-pipeline/src/sidecar.rs#L174), [`core/dr-pipeline/src/sidecar.rs:193`](../../core/dr-pipeline/src/sidecar.rs#L193), [`core/dr-pipeline/src/sidecar.rs:2874`](../../core/dr-pipeline/src/sidecar.rs#L2874), [`core/dr-pipeline/src/sidecar.rs:2892`](../../core/dr-pipeline/src/sidecar.rs#L2892), [`core/dr-pipeline/src/sidecar.rs:590`](../../core/dr-pipeline/src/sidecar.rs#L590), [`ui/dr-ui/src/collections_ui/controller.rs:16`](../../ui/dr-ui/src/collections_ui/controller.rs#L16), [`ui/dr-ui/src/collections_ui/controller.rs:209`](../../ui/dr-ui/src/collections_ui/controller.rs#L209), [`ui/dr-ui/src/collections_ui/controller.rs:418`](../../ui/dr-ui/src/collections_ui/controller.rs#L418), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:150`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L150), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:264`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L264), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:323`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L323), [`ui/dr-ui/src/decoder_seam.rs:184`](../../ui/dr-ui/src/decoder_seam.rs#L184), [`ui/dr-ui/src/library/cells.rs:198`](../../ui/dr-ui/src/library/cells.rs#L198), [`ui/dr-ui/src/library/scan.rs:526`](../../ui/dr-ui/src/library/scan.rs#L526), [`ui/dr-ui/src/library/scan.rs:916`](../../ui/dr-ui/src/library/scan.rs#L916), [`ui/dr-ui/src/library_ui/controller.rs:836`](../../ui/dr-ui/src/library_ui/controller.rs#L836), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1144`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1144), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1168`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1168), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1185`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1185), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:118`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L118), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1391`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1391), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1402`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1402), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1415`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1415), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1430`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1430), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1439`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1439), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:97`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L97), [`ui/dr-ui/tests/status_is_not_colour_alone.rs:177`](../../ui/dr-ui/tests/status_is_not_colour_alone.rs#L177), [`ui/dr-ui/tests/status_is_not_colour_alone.rs:215`](../../ui/dr-ui/tests/status_is_not_colour_alone.rs#L215), [`ui/dr-ui/ui/app.slint:1665`](../../ui/dr-ui/ui/app.slint#L1665), [`ui/dr-ui/ui/app.slint:2809`](../../ui/dr-ui/ui/app.slint#L2809), [`ui/dr-ui/ui/develop.slint:142`](../../ui/dr-ui/ui/develop.slint#L142), [`ui/dr-ui/ui/develop.slint:48`](../../ui/dr-ui/ui/develop.slint#L48), [`ui/dr-ui/ui/labels.slint:19`](../../ui/dr-ui/ui/labels.slint#L19), [`ui/dr-ui/ui/labels.slint:1`](../../ui/dr-ui/ui/labels.slint#L1), [`ui/dr-ui/ui/labels.slint:40`](../../ui/dr-ui/ui/labels.slint#L40), [`ui/dr-ui/ui/labels.slint:74`](../../ui/dr-ui/ui/labels.slint#L74), [`ui/dr-ui/ui/library.slint:1614`](../../ui/dr-ui/ui/library.slint#L1614), [`ui/dr-ui/ui/library.slint:1617`](../../ui/dr-ui/ui/library.slint#L1617), [`ui/dr-ui/ui/library.slint:1768`](../../ui/dr-ui/ui/library.slint#L1768), [`ui/dr-ui/ui/library.slint:1792`](../../ui/dr-ui/ui/library.slint#L1792), [`ui/dr-ui/ui/library.slint:1849`](../../ui/dr-ui/ui/library.slint#L1849), [`ui/dr-ui/ui/library.slint:21`](../../ui/dr-ui/ui/library.slint#L21), [`ui/dr-ui/ui/library.slint:3202`](../../ui/dr-ui/ui/library.slint#L3202), [`ui/dr-ui/ui/library.slint:3209`](../../ui/dr-ui/ui/library.slint#L3209), [`ui/dr-ui/ui/library.slint:3229`](../../ui/dr-ui/ui/library.slint#L3229), [`ui/dr-ui/ui/library.slint:3318`](../../ui/dr-ui/ui/library.slint#L3318), [`ui/dr-ui/ui/library.slint:3934`](../../ui/dr-ui/ui/library.slint#L3934), [`ui/dr-ui/ui/library.slint:467`](../../ui/dr-ui/ui/library.slint#L467), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5060`](../../ui/dr-ui/ui/library.slint#L5060), [`ui/dr-ui/ui/library.slint:5076`](../../ui/dr-ui/ui/library.slint#L5076), [`ui/dr-ui/ui/library.slint:515`](../../ui/dr-ui/ui/library.slint#L515), [`ui/dr-ui/ui/library.slint:621`](../../ui/dr-ui/ui/library.slint#L621), [`ui/dr-ui/ui/library.slint:665`](../../ui/dr-ui/ui/library.slint#L665), [`ui/dr-ui/ui/library.slint:671`](../../ui/dr-ui/ui/library.slint#L671), [`ui/dr-ui/ui/library.slint:675`](../../ui/dr-ui/ui/library.slint#L675) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/rating.rs:449`](../../core/dr-catalog/src/rating.rs#L449), [`core/dr-catalog/src/rating.rs:880`](../../core/dr-catalog/src/rating.rs#L880), [`core/dr-catalog/src/schema.rs:1155`](../../core/dr-catalog/src/schema.rs#L1155), [`core/dr-types/src/place.rs:138`](../../core/dr-types/src/place.rs#L138), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library/filters.rs:156`](../../ui/dr-ui/src/library/filters.rs#L156), [`ui/dr-ui/src/library/filters.rs:357`](../../ui/dr-ui/src/library/filters.rs#L357), [`ui/dr-ui/src/library/filters.rs:442`](../../ui/dr-ui/src/library/filters.rs#L442), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/spans.rs:262`](../../ui/dr-ui/src/library/spans.rs#L262), [`ui/dr-ui/src/library_ui/controller.rs:412`](../../ui/dr-ui/src/library_ui/controller.rs#L412), [`ui/dr-ui/src/library_ui/controller.rs:524`](../../ui/dr-ui/src/library_ui/controller.rs#L524), [`ui/dr-ui/src/library_ui/filter_bar.rs:256`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L256), [`ui/dr-ui/src/library_ui/filter_bar.rs:351`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L351), [`ui/dr-ui/src/library_ui/filter_bar.rs:406`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L406), [`ui/dr-ui/src/library_ui/filter_bar.rs:840`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L840), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:85`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L85), [`ui/dr-ui/ui/app.slint:655`](../../ui/dr-ui/ui/app.slint#L655), [`ui/dr-ui/ui/library.slint:113`](../../ui/dr-ui/ui/library.slint#L113), [`ui/dr-ui/ui/library.slint:1936`](../../ui/dr-ui/ui/library.slint#L1936), [`ui/dr-ui/ui/library.slint:1973`](../../ui/dr-ui/ui/library.slint#L1973), [`ui/dr-ui/ui/library.slint:2703`](../../ui/dr-ui/ui/library.slint#L2703), [`ui/dr-ui/ui/library.slint:3229`](../../ui/dr-ui/ui/library.slint#L3229), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5060`](../../ui/dr-ui/ui/library.slint#L5060), [`ui/dr-ui/ui/library.slint:621`](../../ui/dr-ui/ui/library.slint#L621), [`ui/dr-ui/ui/library.slint:679`](../../ui/dr-ui/ui/library.slint#L679), [`ui/dr-ui/ui/library.slint:701`](../../ui/dr-ui/ui/library.slint#L701), [`ui/dr-ui/ui/settings.slint:202`](../../ui/dr-ui/ui/settings.slint#L202), [`ui/dr-ui/ui/widgets.slint:271`](../../ui/dr-ui/ui/widgets.slint#L271) | -| FR-CAT-7 | [`core/dr-catalog/src/albums.rs:1`](../../core/dr-catalog/src/albums.rs#L1), [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui/controller.rs:263`](../../ui/dr-ui/src/collections_ui/controller.rs#L263), [`ui/dr-ui/src/collections_ui/controller.rs:273`](../../ui/dr-ui/src/collections_ui/controller.rs#L273), [`ui/dr-ui/src/collections_ui/controller.rs:296`](../../ui/dr-ui/src/collections_ui/controller.rs#L296), [`ui/dr-ui/src/collections_ui/controller.rs:317`](../../ui/dr-ui/src/collections_ui/controller.rs#L317), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:274`](../../ui/dr-ui/src/collections_ui/drag.rs#L274), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:258`](../../ui/dr-ui/src/collections_ui/press.rs#L258), [`ui/dr-ui/src/collections_ui/press.rs:490`](../../ui/dr-ui/src/collections_ui/press.rs#L490), [`ui/dr-ui/src/collections_ui/press.rs:54`](../../ui/dr-ui/src/collections_ui/press.rs#L54), [`ui/dr-ui/src/collections_ui/rename_menu.rs:196`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L196), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:251`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L251), [`ui/dr-ui/src/collections_ui/tree_sync.rs:322`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L322), [`ui/dr-ui/src/collections_ui/tree_sync.rs:344`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:121`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L121), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:171`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L171), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:444`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L444), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:522`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L522), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:586`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L586), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:344`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:394`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L394), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:517`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L517), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:581`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L581), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:131`](../../ui/dr-ui/src/library/spans.rs#L131), [`ui/dr-ui/src/library/spans.rs:50`](../../ui/dr-ui/src/library/spans.rs#L50), [`ui/dr-ui/src/library/spans.rs:76`](../../ui/dr-ui/src/library/spans.rs#L76), [`ui/dr-ui/src/library_ui/sync.rs:235`](../../ui/dr-ui/src/library_ui/sync.rs#L235), [`ui/dr-ui/src/library_ui/timeline.rs:513`](../../ui/dr-ui/src/library_ui/timeline.rs#L513), [`ui/dr-ui/src/library_ui/window.rs:359`](../../ui/dr-ui/src/library_ui/window.rs#L359), [`ui/dr-ui/src/library_ui/window.rs:395`](../../ui/dr-ui/src/library_ui/window.rs#L395), [`ui/dr-ui/ui/app.slint:3508`](../../ui/dr-ui/ui/app.slint#L3508), [`ui/dr-ui/ui/collections.slint:1283`](../../ui/dr-ui/ui/collections.slint#L1283), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:616`](../../ui/dr-ui/ui/collections.slint#L616), [`ui/dr-ui/ui/library.slint:1367`](../../ui/dr-ui/ui/library.slint#L1367), [`ui/dr-ui/ui/library.slint:1734`](../../ui/dr-ui/ui/library.slint#L1734), [`ui/dr-ui/ui/library.slint:3685`](../../ui/dr-ui/ui/library.slint#L3685), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5038`](../../ui/dr-ui/ui/library.slint#L5038), [`ui/dr-ui/ui/library.slint:5180`](../../ui/dr-ui/ui/library.slint#L5180), [`ui/dr-ui/ui/library.slint:518`](../../ui/dr-ui/ui/library.slint#L518), [`ui/dr-ui/ui/library.slint:567`](../../ui/dr-ui/ui/library.slint#L567), [`ui/dr-ui/ui/library.slint:577`](../../ui/dr-ui/ui/library.slint#L577) | +| FR-CAT-7 | [`core/dr-catalog/src/albums.rs:1`](../../core/dr-catalog/src/albums.rs#L1), [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/dedup_people.rs:1`](../../core/dr-catalog/src/dedup_people.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui/controller.rs:263`](../../ui/dr-ui/src/collections_ui/controller.rs#L263), [`ui/dr-ui/src/collections_ui/controller.rs:273`](../../ui/dr-ui/src/collections_ui/controller.rs#L273), [`ui/dr-ui/src/collections_ui/controller.rs:296`](../../ui/dr-ui/src/collections_ui/controller.rs#L296), [`ui/dr-ui/src/collections_ui/controller.rs:317`](../../ui/dr-ui/src/collections_ui/controller.rs#L317), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:274`](../../ui/dr-ui/src/collections_ui/drag.rs#L274), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:258`](../../ui/dr-ui/src/collections_ui/press.rs#L258), [`ui/dr-ui/src/collections_ui/press.rs:490`](../../ui/dr-ui/src/collections_ui/press.rs#L490), [`ui/dr-ui/src/collections_ui/press.rs:54`](../../ui/dr-ui/src/collections_ui/press.rs#L54), [`ui/dr-ui/src/collections_ui/rename_menu.rs:196`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L196), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:251`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L251), [`ui/dr-ui/src/collections_ui/tree_sync.rs:322`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L322), [`ui/dr-ui/src/collections_ui/tree_sync.rs:344`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:121`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L121), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:171`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L171), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:444`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L444), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:522`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L522), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:586`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L586), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:287`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L287), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:344`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L344), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:394`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L394), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:517`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L517), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:581`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L581), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:131`](../../ui/dr-ui/src/library/spans.rs#L131), [`ui/dr-ui/src/library/spans.rs:50`](../../ui/dr-ui/src/library/spans.rs#L50), [`ui/dr-ui/src/library/spans.rs:76`](../../ui/dr-ui/src/library/spans.rs#L76), [`ui/dr-ui/src/library_ui/sync.rs:235`](../../ui/dr-ui/src/library_ui/sync.rs#L235), [`ui/dr-ui/src/library_ui/timeline.rs:513`](../../ui/dr-ui/src/library_ui/timeline.rs#L513), [`ui/dr-ui/src/library_ui/window.rs:359`](../../ui/dr-ui/src/library_ui/window.rs#L359), [`ui/dr-ui/src/library_ui/window.rs:395`](../../ui/dr-ui/src/library_ui/window.rs#L395), [`ui/dr-ui/ui/app.slint:3508`](../../ui/dr-ui/ui/app.slint#L3508), [`ui/dr-ui/ui/collections.slint:1283`](../../ui/dr-ui/ui/collections.slint#L1283), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:616`](../../ui/dr-ui/ui/collections.slint#L616), [`ui/dr-ui/ui/library.slint:1367`](../../ui/dr-ui/ui/library.slint#L1367), [`ui/dr-ui/ui/library.slint:1734`](../../ui/dr-ui/ui/library.slint#L1734), [`ui/dr-ui/ui/library.slint:3685`](../../ui/dr-ui/ui/library.slint#L3685), [`ui/dr-ui/ui/library.slint:488`](../../ui/dr-ui/ui/library.slint#L488), [`ui/dr-ui/ui/library.slint:5038`](../../ui/dr-ui/ui/library.slint#L5038), [`ui/dr-ui/ui/library.slint:5180`](../../ui/dr-ui/ui/library.slint#L5180), [`ui/dr-ui/ui/library.slint:518`](../../ui/dr-ui/ui/library.slint#L518), [`ui/dr-ui/ui/library.slint:567`](../../ui/dr-ui/ui/library.slint#L567), [`ui/dr-ui/ui/library.slint:577`](../../ui/dr-ui/ui/library.slint#L577) | | FR-CAT-8 | [`core/dr-catalog/src/schema.rs:770`](../../core/dr-catalog/src/schema.rs#L770), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:642`](../../core/dr-pipeline/src/graph.rs#L642), [`core/dr-pipeline/src/ops/curve.rs:137`](../../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1433`](../../core/dr-pipeline/src/sidecar.rs#L1433), [`core/dr-pipeline/src/sidecar.rs:2273`](../../core/dr-pipeline/src/sidecar.rs#L2273), [`core/dr-pipeline/src/sidecar.rs:99`](../../core/dr-pipeline/src/sidecar.rs#L99), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/masks.rs:743`](../../ui/dr-ui/src/develop/masks.rs#L743), [`ui/dr-ui/src/develop/session.rs:566`](../../ui/dr-ui/src/develop/session.rs#L566), [`ui/dr-ui/src/develop/session.rs:600`](../../ui/dr-ui/src/develop/session.rs#L600), [`ui/dr-ui/src/export.rs:922`](../../ui/dr-ui/src/export.rs#L922), [`ui/dr-ui/src/lib.rs:1197`](../../ui/dr-ui/src/lib.rs#L1197), [`ui/dr-ui/src/lib.rs:1257`](../../ui/dr-ui/src/lib.rs#L1257), [`ui/dr-ui/src/lib.rs:2655`](../../ui/dr-ui/src/lib.rs#L2655), [`ui/dr-ui/src/lib.rs:2848`](../../ui/dr-ui/src/lib.rs#L2848), [`ui/dr-ui/src/lib.rs:808`](../../ui/dr-ui/src/lib.rs#L808), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:102`](../../ui/dr-ui/src/library/sidecar.rs#L102), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:380`](../../ui/dr-ui/src/library/sidecar.rs#L380), [`ui/dr-ui/src/library/sidecar.rs:65`](../../ui/dr-ui/src/library/sidecar.rs#L65), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui/grid.rs:778`](../../ui/dr-ui/src/library_ui/grid.rs#L778), [`ui/dr-ui/src/library_ui/open.rs:455`](../../ui/dr-ui/src/library_ui/open.rs#L455), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:1212`](../../core/dr-catalog/src/schema.rs#L1212), [`core/dr-catalog/src/walk.rs:161`](../../core/dr-catalog/src/walk.rs#L161), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:432`](../../core/dr-catalog/src/walk.rs#L432), [`core/dr-catalog/src/walk.rs:695`](../../core/dr-catalog/src/walk.rs#L695), [`core/dr-catalog/src/walk.rs:989`](../../core/dr-catalog/src/walk.rs#L989), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop/render.rs:626`](../../ui/dr-ui/src/develop/render.rs#L626), [`ui/dr-ui/src/develop/render.rs:669`](../../ui/dr-ui/src/develop/render.rs#L669), [`ui/dr-ui/src/library/filters.rs:89`](../../ui/dr-ui/src/library/filters.rs#L89), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:635`](../../ui/dr-ui/src/library/scan.rs#L635), [`ui/dr-ui/src/library/sidecar.rs:102`](../../ui/dr-ui/src/library/sidecar.rs#L102), [`ui/dr-ui/src/library/sidecar.rs:364`](../../ui/dr-ui/src/library/sidecar.rs#L364), [`ui/dr-ui/src/library/sidecar.rs:380`](../../ui/dr-ui/src/library/sidecar.rs#L380), [`ui/dr-ui/src/library/sidecar.rs:434`](../../ui/dr-ui/src/library/sidecar.rs#L434), [`ui/dr-ui/src/library/spans.rs:378`](../../ui/dr-ui/src/library/spans.rs#L378), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library/thumbnails_gen.rs:74`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L74), [`ui/dr-ui/src/library_ui/controller.rs:300`](../../ui/dr-ui/src/library_ui/controller.rs#L300), [`ui/dr-ui/src/library_ui/controller.rs:336`](../../ui/dr-ui/src/library_ui/controller.rs#L336), [`ui/dr-ui/src/library_ui/controller.rs:344`](../../ui/dr-ui/src/library_ui/controller.rs#L344), [`ui/dr-ui/src/library_ui/controller.rs:592`](../../ui/dr-ui/src/library_ui/controller.rs#L592), [`ui/dr-ui/src/library_ui/controller.rs:709`](../../ui/dr-ui/src/library_ui/controller.rs#L709), [`ui/dr-ui/src/library_ui/filter_bar.rs:461`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L461), [`ui/dr-ui/src/library_ui/filter_bar.rs:547`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L547), [`ui/dr-ui/src/library_ui/offline.rs:561`](../../ui/dr-ui/src/library_ui/offline.rs#L561), [`ui/dr-ui/src/library_ui/offline.rs:607`](../../ui/dr-ui/src/library_ui/offline.rs#L607), [`ui/dr-ui/src/library_ui/offline.rs:623`](../../ui/dr-ui/src/library_ui/offline.rs#L623), [`ui/dr-ui/src/library_ui/open.rs:535`](../../ui/dr-ui/src/library_ui/open.rs#L535), [`ui/dr-ui/src/library_ui/open.rs:615`](../../ui/dr-ui/src/library_ui/open.rs#L615), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:75`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L75), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:883`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L883), [`ui/dr-ui/src/library_ui/sync.rs:287`](../../ui/dr-ui/src/library_ui/sync.rs#L287), [`ui/dr-ui/src/library_ui/sync.rs:421`](../../ui/dr-ui/src/library_ui/sync.rs#L421), [`ui/dr-ui/src/library_ui/sync.rs:83`](../../ui/dr-ui/src/library_ui/sync.rs#L83), [`ui/dr-ui/src/library_ui/window.rs:678`](../../ui/dr-ui/src/library_ui/window.rs#L678), [`ui/dr-ui/src/library_ui/window.rs:958`](../../ui/dr-ui/src/library_ui/window.rs#L958), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1645`](../../core/dr-catalog/src/faces.rs#L1645), [`core/dr-catalog/src/faces.rs:1663`](../../core/dr-catalog/src/faces.rs#L1663), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1041`](../../core/dr-catalog/src/schema.rs#L1041), [`core/dr-catalog/src/schema.rs:633`](../../core/dr-catalog/src/schema.rs#L633), [`core/dr-catalog/src/schema.rs:811`](../../core/dr-catalog/src/schema.rs#L811), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop/segmentation.rs:104`](../../ui/dr-ui/src/develop/segmentation.rs#L104), [`ui/dr-ui/src/develop/segmentation.rs:170`](../../ui/dr-ui/src/develop/segmentation.rs#L170), [`ui/dr-ui/src/develop/segmentation.rs:590`](../../ui/dr-ui/src/develop/segmentation.rs#L590), [`ui/dr-ui/src/develop/segmentation.rs:95`](../../ui/dr-ui/src/develop/segmentation.rs#L95), [`ui/dr-ui/src/develop/session.rs:23`](../../ui/dr-ui/src/develop/session.rs#L23), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1472`](../../ui/dr-ui/src/identity_ui.rs#L1472), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2820`](../../ui/dr-ui/src/lib.rs#L2820), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1), [`ui/dr-ui/ui/settings.slint:469`](../../ui/dr-ui/ui/settings.slint#L469), [`ui/dr-ui/ui/settings.slint:95`](../../ui/dr-ui/ui/settings.slint#L95) | +| FR-CULL-10 | [`core/dr-catalog/src/dedup_people.rs:1`](../../core/dr-catalog/src/dedup_people.rs#L1), [`core/dr-catalog/src/faces.rs:1662`](../../core/dr-catalog/src/faces.rs#L1662), [`core/dr-catalog/src/faces.rs:1680`](../../core/dr-catalog/src/faces.rs#L1680), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1041`](../../core/dr-catalog/src/schema.rs#L1041), [`core/dr-catalog/src/schema.rs:633`](../../core/dr-catalog/src/schema.rs#L633), [`core/dr-catalog/src/schema.rs:811`](../../core/dr-catalog/src/schema.rs#L811), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop/segmentation.rs:104`](../../ui/dr-ui/src/develop/segmentation.rs#L104), [`ui/dr-ui/src/develop/segmentation.rs:170`](../../ui/dr-ui/src/develop/segmentation.rs#L170), [`ui/dr-ui/src/develop/segmentation.rs:590`](../../ui/dr-ui/src/develop/segmentation.rs#L590), [`ui/dr-ui/src/develop/segmentation.rs:95`](../../ui/dr-ui/src/develop/segmentation.rs#L95), [`ui/dr-ui/src/develop/session.rs:23`](../../ui/dr-ui/src/develop/session.rs#L23), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1472`](../../ui/dr-ui/src/identity_ui.rs#L1472), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2820`](../../ui/dr-ui/src/lib.rs#L2820), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1), [`ui/dr-ui/ui/settings.slint:469`](../../ui/dr-ui/ui/settings.slint#L469), [`ui/dr-ui/ui/settings.slint:95`](../../ui/dr-ui/ui/settings.slint#L95) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1041`](../../core/dr-catalog/src/schema.rs#L1041), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library/filters.rs:110`](../../ui/dr-ui/src/library/filters.rs#L110), [`ui/dr-ui/src/library/filters.rs:164`](../../ui/dr-ui/src/library/filters.rs#L164), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1041`](../../core/dr-catalog/src/schema.rs#L1041), [`core/dr-catalog/src/schema.rs:633`](../../core/dr-catalog/src/schema.rs#L633), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-12 | [`core/dr-catalog/src/dedup_people.rs:1`](../../core/dr-catalog/src/dedup_people.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1041`](../../core/dr-catalog/src/schema.rs#L1041), [`core/dr-catalog/src/schema.rs:633`](../../core/dr-catalog/src/schema.rs#L633), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-13 | [`core/dr-catalog/src/schema.rs:947`](../../core/dr-catalog/src/schema.rs#L947), [`core/dr-types/src/place.rs:133`](../../core/dr-types/src/place.rs#L133), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library/filters.rs:136`](../../ui/dr-ui/src/library/filters.rs#L136), [`ui/dr-ui/src/library/filters.rs:763`](../../ui/dr-ui/src/library/filters.rs#L763), [`ui/dr-ui/src/library/filters.rs:806`](../../ui/dr-ui/src/library/filters.rs#L806), [`ui/dr-ui/src/library/filters.rs:844`](../../ui/dr-ui/src/library/filters.rs#L844), [`ui/dr-ui/ui/identity.slint:64`](../../ui/dr-ui/ui/identity.slint#L64), [`ui/dr-ui/ui/library.slint:1928`](../../ui/dr-ui/ui/library.slint#L1928), [`ui/dr-ui/ui/library.slint:735`](../../ui/dr-ui/ui/library.slint#L735) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/decoder_seam.rs:215`](../../ui/dr-ui/src/decoder_seam.rs#L215), [`ui/dr-ui/src/import.rs:474`](../../ui/dr-ui/src/import.rs#L474) | | FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:1364`](../../ui/dr-ui/src/develop/render.rs#L1364), [`ui/dr-ui/src/develop/render.rs:1428`](../../ui/dr-ui/src/develop/render.rs#L1428), [`ui/dr-ui/src/develop/render.rs:1451`](../../ui/dr-ui/src/develop/render.rs#L1451), [`ui/dr-ui/src/develop/render.rs:317`](../../ui/dr-ui/src/develop/render.rs#L317), [`ui/dr-ui/src/develop/render.rs:330`](../../ui/dr-ui/src/develop/render.rs#L330), [`ui/dr-ui/src/develop/render.rs:375`](../../ui/dr-ui/src/develop/render.rs#L375), [`ui/dr-ui/src/develop/render.rs:386`](../../ui/dr-ui/src/develop/render.rs#L386), [`ui/dr-ui/src/develop/render.rs:392`](../../ui/dr-ui/src/develop/render.rs#L392), [`ui/dr-ui/src/develop/render.rs:409`](../../ui/dr-ui/src/develop/render.rs#L409), [`ui/dr-ui/src/develop/session.rs:122`](../../ui/dr-ui/src/develop/session.rs#L122), [`ui/dr-ui/src/develop/session.rs:142`](../../ui/dr-ui/src/develop/session.rs#L142), [`ui/dr-ui/src/develop/session.rs:148`](../../ui/dr-ui/src/develop/session.rs#L148), [`ui/dr-ui/src/develop_ui.rs:1420`](../../ui/dr-ui/src/develop_ui.rs#L1420), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1286`](../../ui/dr-ui/src/lib.rs#L1286), [`ui/dr-ui/src/lib.rs:2169`](../../ui/dr-ui/src/lib.rs#L2169), [`ui/dr-ui/src/lib.rs:2299`](../../ui/dr-ui/src/lib.rs#L2299), [`ui/dr-ui/src/lib.rs:2342`](../../ui/dr-ui/src/lib.rs#L2342), [`ui/dr-ui/src/lib.rs:479`](../../ui/dr-ui/src/lib.rs#L479), [`ui/dr-ui/src/peaking.rs:1`](../../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:182`](../../ui/dr-ui/ui/app.slint#L182), [`ui/dr-ui/ui/app.slint:1904`](../../ui/dr-ui/ui/app.slint#L1904), [`ui/dr-ui/ui/app.slint:3281`](../../ui/dr-ui/ui/app.slint#L3281), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../../ui/dr-ui/ui/peaking.slint#L56), [`ui/dr-ui/ui/peaking.slint:91`](../../ui/dr-ui/ui/peaking.slint#L91) | @@ -180,7 +180,7 @@ _None._ | NFR-R3 | [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | | NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:408`](../../ui/dr-ui/src/library_ui/controller.rs#L408), [`ui/dr-ui/src/library_ui/controller.rs:556`](../../ui/dr-ui/src/library_ui/controller.rs#L556), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:693`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L693), [`ui/dr-ui/src/settings_ui.rs:524`](../../ui/dr-ui/src/settings_ui.rs#L524), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:210`](../../ui/dr-ui/ui/settings.slint#L210), [`ui/dr-ui/ui/settings.slint:989`](../../ui/dr-ui/ui/settings.slint#L989) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../../core/dr-catalog/src/schema.rs#L1) | -| NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:269`](../../core/dr-catalog/src/lib.rs#L269), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1) | +| NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:270`](../../core/dr-catalog/src/lib.rs#L270), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../../core/dr-gpu/src/error.rs#L1) | | NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1322`](../../core/dr-gpu/src/adjust.rs#L1322), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop/render.rs:561`](../../ui/dr-ui/src/develop/render.rs#L561), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:115`](../../ui/dr-ui/src/lib.rs#L115), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) |