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) |