From aac313640721fac187916ef294d57b2a201a8bb1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 26 Aug 2026 20:02:52 +0200 Subject: [PATCH] Store faces and the people they belong to Schema v8: people, faces, face_person, face_person_rejected, and the per-library calibration. Follows catalog.md 10.1 with two additions the spec work turned up. crop_px, because at the 1024px proxy tier a group shot reaches the embedder at ~50 source pixels upsampled to 112 and a portrait at 340. FR-CULL-9 names face size as an axis along which an uncalibrated similarity misbehaves, so it is a stored feature rather than a UI hint. face_person_rejected, because rejection is not the absence of an assignment. Without it the next clustering pass re-suggests exactly the face the user just pushed away, and the tool feels broken. record_detections replaces rather than appends, since DetectFaces is coalesced per image -- and carries confirmations across the replacement by box overlap, so re-indexing with a better model cannot discard the user's own labelling. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/faces.rs | 1092 +++++++++++++++++++++++++++++++++ core/dr-catalog/src/lib.rs | 3 + core/dr-catalog/src/schema.rs | 116 +++- docs/traceability.md | 44 +- 4 files changed, 1232 insertions(+), 23 deletions(-) create mode 100644 core/dr-catalog/src/faces.rs diff --git a/core/dr-catalog/src/faces.rs b/core/dr-catalog/src/faces.rs new file mode 100644 index 0000000..58c4d61 --- /dev/null +++ b/core/dr-catalog/src/faces.rs @@ -0,0 +1,1092 @@ +//! TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 +//! People and faces: what was detected, who it is, and who said so. +//! +//! The storage half of docs/faces.md. `dr-face` finds faces and turns them into +//! 512 numbers; this module is where those numbers acquire an identity, and +//! where the user's corrections outrank the model's guesses. +//! +//! # Two kinds of fact, never conflated +//! +//! A face is either **suggested** — the system's inference, recomputable at +//! will — or **confirmed**, the user's judgement, which no later indexing pass +//! may overwrite (FR-CULL-10). That distinction is a column, not a probability +//! of 1.0, because collapsing them would lose the ability to recompute +//! suggestions without touching user data. +//! +//! It runs in both directions. [`reject`] records that a face is *not* someone, +//! and that has to be stored rather than inferred from the absence of an +//! assignment: without it the next clustering pass re-suggests exactly the face +//! the user just pushed away. +//! +//! # What is derived and what is not +//! +//! Everything here is rebuildable by re-indexing except the person's name and +//! the user's confirmations and rejections. The embeddings are expensive and +//! reproducible, which is precisely what ARCH §6.12 says belongs in a +//! disposable index; the name is irreplaceable and goes to the sidecar +//! (FR-CULL-12), which is not this module's job. +//! +//! # Privacy is structural here, not policy +//! +//! NFR-SEC-5 puts face data under a stricter rule than the rest of the catalog: +//! it never enters a diagnostics bundle, never reaches a plugin, and is +//! deletable in one action ([`delete_all_face_data`]). This module has no +//! network dependency and no path that emits an embedding anywhere but back to +//! its caller. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::ImageId; + +use crate::error::CatalogError; + +/// A face's row id. Local to this catalog, like [`crate::keywords::KeywordId`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct FaceId(pub u64); + +/// A person's row id. Local; [`Person::uuid`] is what a merge keys on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PersonId(pub u64); + +/// A face as detected, before it has an identity. +/// +/// Coordinates are **normalised to the image's long edge**, so a face outlives +/// the proxy it was found on being evicted and regenerated at another size. +#[derive(Debug, Clone, PartialEq)] +pub struct DetectedFace { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + /// Five `(x, y)` pairs, normalised the same way. + pub landmarks: [(f32, f32); 5], + pub confidence: f32, + /// 512 × f16, L2-normalised — `dr_face::Embedding::to_f16_bytes`. + pub embedding: Vec, + /// Source pixels across the aligned crop (docs/faces.md §7). + pub crop_px: f32, + /// Which model produced the embedding. Comparing across models is the one + /// mistake that yields plausible garbage rather than an error. + pub model_id: String, +} + +/// A stored face, with whatever identity it has acquired. +#[derive(Debug, Clone, PartialEq)] +pub struct Face { + pub id: FaceId, + pub image_id: ImageId, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, + pub landmarks: [(f32, f32); 5], + pub confidence: f32, + pub crop_px: f32, + pub model_id: String, + /// `None` when the face belongs to no one yet. + pub person: Option, + /// Calibrated P(this face is this person). Meaningless without `person`. + pub probability: f32, + /// Whether the user asserted the assignment, as against the system guessing. + pub confirmed: bool, +} + +/// A person, and how much of them the library holds. +#[derive(Debug, Clone, PartialEq)] +pub struct Person { + pub id: PersonId, + pub uuid: String, + pub name: String, + /// Faces the user has confirmed. + pub confirmed_faces: u64, + /// Faces the system suggests and the user has not ruled on. + pub suggested_faces: u64, +} + +/// The FR-CULL-9 calibration for one model. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Calibration { + pub a: f32, + pub b: f32, + pub w_size: f32, + /// Whether the fit is usable. When false the UI says confidence is + /// unavailable rather than presenting an untuned default as a measurement. + pub valid: bool, + pub positive_pairs: u64, + pub negative_pairs: u64, +} + +impl Calibration { + /// P(same person), optionally shifted by a base-rate prior. + /// + /// `log_prior_odds` is applied at evaluation rather than baked into the + /// fit, so one stored calibration serves every context: the odds that two + /// faces in a 40-image album match are not the odds in a 40,000-image + /// archive. Baking a prior in would need a refit per context and would make + /// the stored parameters mean different things depending on where they came + /// from. + pub fn probability(&self, cosine: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 { + let z = self.a * cosine + + self.b + + self.w_size * min_crop_px.max(1.0).log2() + + log_prior_odds; + // Branch on the sign so neither tail overflows: exp(-z) for large + // positive z, exp(z) for large negative. + if z >= 0.0 { + 1.0 / (1.0 + (-z).exp()) + } else { + let e = z.exp(); + e / (1.0 + e) + } + } + + /// The cosine at which [`Calibration::probability`] crosses `p`. + /// + /// What turns "merge above 0.9" into an actual comparison against a stored + /// similarity, without evaluating the sigmoid per candidate edge. + pub fn boundary_at(&self, p: f32, min_crop_px: f32, log_prior_odds: f32) -> f32 { + ((p / (1.0 - p)).ln() + - self.b + - self.w_size * min_crop_px.max(1.0).log2() + - log_prior_odds) + / self.a + } +} + +// ── detection ───────────────────────────────────────────────────────────── + +/// Replace every face on an image with a fresh detection pass. +/// +/// Replace rather than append, because `DetectFaces` is coalesced per image +/// (FR-CAT-3) and re-running it must be idempotent — appending would double the +/// faces on every re-index and silently inflate every cluster. +/// +/// **Confirmed assignments survive** where the new detection covers the same +/// part of the frame: a re-index with a better model must not discard the +/// user's labelling (FR-CULL-10). Matching is by box overlap, since the face is +/// in the same place even when the box moves a little. +pub fn record_detections( + conn: &Connection, + image_id: ImageId, + faces: &[DetectedFace], +) -> Result, CatalogError> { + let tx = conn.unchecked_transaction()?; + + // What the user had asserted, so it can be carried across the replacement. + let mut prior: Vec<(f32, f32, f32, f32, i64, f64)> = Vec::new(); + { + let mut q = tx.prepare( + "SELECT f.x, f.y, f.w, f.h, fp.person_id, fp.probability + FROM faces f + JOIN face_person fp ON fp.face_id = f.id + WHERE f.image_id = ?1 AND fp.confirmed = 1", + )?; + let rows = q.query_map([image_id.0 as i64], |r| { + Ok(( + r.get::<_, f64>(0)? as f32, + r.get::<_, f64>(1)? as f32, + r.get::<_, f64>(2)? as f32, + r.get::<_, f64>(3)? as f32, + r.get::<_, i64>(4)?, + r.get::<_, f64>(5)?, + )) + })?; + for row in rows { + prior.push(row?); + } + } + + tx.execute("DELETE FROM faces WHERE image_id = ?1", [image_id.0 as i64])?; + + let now = now_secs(); + let mut ids = Vec::with_capacity(faces.len()); + for f in faces { + tx.execute( + "INSERT INTO faces + (image_id, x, y, w, h, landmarks, detector_confidence, + embedding, crop_px, model_id, detected_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + image_id.0 as i64, + f.x as f64, + f.y as f64, + f.w as f64, + f.h as f64, + landmarks_to_blob(&f.landmarks), + f.confidence as f64, + f.embedding, + f.crop_px as f64, + f.model_id, + now, + ], + )?; + let id = FaceId(tx.last_insert_rowid() as u64); + + // Re-attach a confirmation whose box this detection clearly replaces. + // 0.5 IoU is loose on purpose: the question is "is this the same face + // in the frame", not "is this the same box", and a better detector is + // entitled to move the box. + if let Some((_, _, _, _, person, prob)) = prior + .iter() + .filter(|p| iou((p.0, p.1, p.2, p.3), (f.x, f.y, f.w, f.h)) > 0.5) + .max_by(|a, b| { + iou((a.0, a.1, a.2, a.3), (f.x, f.y, f.w, f.h)) + .total_cmp(&iou((b.0, b.1, b.2, b.3), (f.x, f.y, f.w, f.h))) + }) + { + tx.execute( + "INSERT INTO face_person (face_id, person_id, probability, confirmed) + VALUES (?1, ?2, ?3, 1)", + rusqlite::params![id.0 as i64, person, prob], + )?; + } + + ids.push(id); + } + + tx.commit()?; + Ok(ids) +} + +/// Every face on one image. +pub fn for_image(conn: &Connection, image_id: ImageId) -> Result, CatalogError> { + let mut q = conn.prepare( + "SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks, + f.detector_confidence, f.crop_px, f.model_id, + fp.person_id, fp.probability, fp.confirmed + FROM faces f + LEFT JOIN face_person fp ON fp.face_id = f.id + WHERE f.image_id = ?1 + ORDER BY f.w * f.h DESC", + )?; + let rows = q.query_map([image_id.0 as i64], read_face)?; + rows.collect::>().map_err(Into::into) +} + +/// Faces with no identity yet, for the clustering pass to work on. +/// +/// Rejections are not exclusions here: a face rejected from one person is still +/// unassigned and still belongs in the next clustering round, just not in that +/// person's cluster. +pub fn unassigned(conn: &Connection, model_id: &str) -> Result, CatalogError> { + let mut q = conn.prepare( + "SELECT f.id FROM faces f + LEFT JOIN face_person fp ON fp.face_id = f.id + WHERE fp.face_id IS NULL AND f.model_id = ?1", + )?; + let rows = q.query_map([model_id], |r| Ok(FaceId(r.get::<_, i64>(0)? as u64)))?; + rows.collect::>().map_err(Into::into) +} + +/// Embeddings for clustering, oldest first so the pass is deterministic. +/// +/// Returned as raw f16 blobs rather than decoded vectors: the caller is +/// `dr-face`, which owns the decoding, and a catalog that widened them here +/// would double the memory of the one operation that holds them all at once. +pub fn embeddings( + conn: &Connection, + model_id: &str, +) -> Result, f32)>, CatalogError> { + let mut q = conn.prepare( + "SELECT id, image_id, embedding, crop_px FROM faces + WHERE model_id = ?1 ORDER BY id", + )?; + let rows = q.query_map([model_id], |r| { + Ok(( + FaceId(r.get::<_, i64>(0)? as u64), + ImageId(r.get::<_, i64>(1)? as u64), + r.get::<_, Vec>(2)?, + r.get::<_, f64>(3)? as f32, + )) + })?; + rows.collect::>().map_err(Into::into) +} + +// ── people ──────────────────────────────────────────────────────────────── + +/// Create a person. +/// +/// A person is a UUID first and a name second (FR-CULL-10): the name is what +/// the user typed, the uuid is what a cross-device merge keys on, and renaming +/// changes only the former. +pub fn create_person(conn: &Connection, name: &str) -> Result { + let now = now_secs(); + conn.execute( + "INSERT INTO people (uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 1, ?3)", + rusqlite::params![new_uuid(), name, now], + )?; + Ok(PersonId(conn.last_insert_rowid() as u64)) +} + +/// Rename a person. The identity is untouched. +pub fn rename_person( + conn: &Connection, + person: PersonId, + name: &str, +) -> Result<(), CatalogError> { + conn.execute( + "UPDATE people SET name = ?2, revision = revision + 1, modified = ?3 + WHERE id = ?1", + rusqlite::params![person.0 as i64, name, now_secs()], + )?; + Ok(()) +} + +/// Everyone in the library, with their face counts, most-photographed first. +/// +/// Merged-away people are excluded: they exist as redirects so a sync does not +/// resurrect them, not as entries in a list. +pub fn people(conn: &Connection) -> Result, CatalogError> { + let mut q = conn.prepare( + "SELECT p.id, p.uuid, p.name, + COALESCE(SUM(fp.confirmed = 1), 0), + COALESCE(SUM(fp.confirmed = 0), 0) + FROM people p + LEFT JOIN face_person fp ON fp.person_id = p.id + WHERE p.merged_into IS NULL + GROUP BY p.id + ORDER BY 4 DESC, 5 DESC, p.name", + )?; + let rows = q.query_map([], |r| { + Ok(Person { + id: PersonId(r.get::<_, i64>(0)? as u64), + uuid: r.get(1)?, + name: r.get(2)?, + confirmed_faces: r.get::<_, i64>(3)? as u64, + suggested_faces: r.get::<_, i64>(4)? as u64, + }) + })?; + rows.collect::>().map_err(Into::into) +} + +/// Fold `source` into `target`, keeping `target`'s identity. +/// +/// The source is not deleted. It is left as a redirect, because a device that +/// still holds it would otherwise resurrect it on the next sync — the same +/// hazard collections have, solved the same way (FR-CAT-7). +/// +/// Confirmations survive the move: a face the user confirmed as the source +/// person is now a confirmed face of the target, which is what the user meant +/// by saying they are the same person. +pub fn merge_people( + conn: &Connection, + target: PersonId, + source: PersonId, +) -> Result { + if target == source { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + + // 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 + // target's row wins and the source's is dropped. + tx.execute( + "DELETE FROM face_person + WHERE person_id = ?2 + AND face_id IN (SELECT face_id FROM face_person WHERE person_id = ?1)", + rusqlite::params![target.0 as i64, source.0 as i64], + )?; + let moved = tx.execute( + "UPDATE face_person SET person_id = ?1 WHERE person_id = ?2", + rusqlite::params![target.0 as i64, source.0 as i64], + )?; + 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()], + )?; + tx.commit()?; + Ok(moved as u64) +} + +/// Follow a merge redirect to the person that outlived it. +pub fn resolve_person( + conn: &Connection, + person: PersonId, +) -> Result { + let mut at = person; + // Bounded rather than `loop`: a redirect cycle would otherwise hang the UI + // thread, and a corrupt index is exactly the case this has to survive. + for _ in 0..32 { + let next: Option = conn + .query_row( + "SELECT merged_into FROM people WHERE id = ?1", + [at.0 as i64], + |r| r.get(0), + ) + .optional()? + .flatten(); + match next { + Some(n) => at = PersonId(n as u64), + None => return Ok(at), + } + } + Ok(at) +} + +// ── assignment ──────────────────────────────────────────────────────────── + +/// Record the system's guess that a face is a person. +/// +/// Never overwrites a confirmation. That is the invariant FR-CULL-10 turns on: +/// a later inference pass may revise every suggestion it likes and may not +/// touch a single thing the user asserted. +pub fn suggest( + conn: &Connection, + face: FaceId, + person: PersonId, + probability: f32, +) -> Result { + // A rejection is a standing instruction, not a one-off: re-suggesting a + // face the user pushed away from this person is the behaviour that makes + // the feature feel broken. + let rejected: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM face_person_rejected + WHERE face_id = ?1 AND person_id = ?2)", + rusqlite::params![face.0 as i64, person.0 as i64], + |r| r.get(0), + )?; + if rejected { + return Ok(false); + } + + let n = conn.execute( + "INSERT INTO face_person (face_id, person_id, probability, confirmed) + VALUES (?1, ?2, ?3, 0) + ON CONFLICT(face_id) DO UPDATE SET + person_id = excluded.person_id, + probability = excluded.probability + WHERE face_person.confirmed = 0", + rusqlite::params![face.0 as i64, person.0 as i64, probability as f64], + )?; + Ok(n > 0) +} + +/// The user says this face is this person. +pub fn confirm( + conn: &Connection, + face: FaceId, + person: PersonId, +) -> Result<(), CatalogError> { + let tx = conn.unchecked_transaction()?; + // Confirming overrides an earlier rejection of the same pair: the user has + // changed their mind, and the newer judgement is the one that counts. + tx.execute( + "DELETE FROM face_person_rejected WHERE face_id = ?1 AND person_id = ?2", + rusqlite::params![face.0 as i64, person.0 as i64], + )?; + tx.execute( + "INSERT INTO face_person (face_id, person_id, probability, confirmed) + VALUES (?1, ?2, 1.0, 1) + ON CONFLICT(face_id) DO UPDATE SET + person_id = excluded.person_id, + probability = 1.0, + confirmed = 1", + rusqlite::params![face.0 as i64, person.0 as i64], + )?; + tx.commit()?; + Ok(()) +} + +/// The user says this face is **not** this person. +/// +/// Stored rather than implied by removal, so the next clustering pass does not +/// re-suggest it. This is user data in the same sense a confirmation is +/// (FR-CULL-12) — a judgement, just a negative one. +pub fn reject( + conn: &Connection, + face: FaceId, + person: PersonId, +) -> Result<(), CatalogError> { + let tx = conn.unchecked_transaction()?; + tx.execute( + "INSERT OR IGNORE INTO face_person_rejected (face_id, person_id) + VALUES (?1, ?2)", + rusqlite::params![face.0 as i64, person.0 as i64], + )?; + tx.execute( + "DELETE FROM face_person WHERE face_id = ?1 AND person_id = ?2", + rusqlite::params![face.0 as i64, person.0 as i64], + )?; + tx.commit()?; + Ok(()) +} + +/// Detach a face from whoever it is assigned to, without asserting anything. +/// +/// Distinct from [`reject`]: this is "I do not know", and the face returns to +/// the pool for the next clustering pass to place. Rejection is "not this +/// person", and is remembered. +pub fn unassign(conn: &Connection, face: FaceId) -> Result<(), CatalogError> { + conn.execute("DELETE FROM face_person WHERE face_id = ?1", [face.0 as i64])?; + Ok(()) +} + +/// Faces belonging to a person. +/// +/// `include_suggested` defaults to false at every call site that matters: +/// FR-CULL-11 requires a saved collection not to change membership silently +/// when a later indexing pass revises a guess. +pub fn for_person( + conn: &Connection, + person: PersonId, + include_suggested: bool, +) -> Result, CatalogError> { + let mut q = conn.prepare( + "SELECT f.id, f.image_id, f.x, f.y, f.w, f.h, f.landmarks, + f.detector_confidence, f.crop_px, f.model_id, + fp.person_id, fp.probability, fp.confirmed + FROM faces f + JOIN face_person fp ON fp.face_id = f.id + WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1) + ORDER BY fp.confirmed DESC, fp.probability DESC", + )?; + let rows = q.query_map( + rusqlite::params![person.0 as i64, include_suggested], + read_face, + )?; + rows.collect::>().map_err(Into::into) +} + +/// The photographs a person appears in — the mapping back to files. +/// +/// Distinct images, not faces: a person photographed twice in one frame is one +/// picture of them. This is what [`dr_types::Selector`]'s person term compiles +/// against and what the grid filters on (FR-CULL-11). +pub fn images_for_person( + conn: &Connection, + person: PersonId, + include_suggested: bool, +) -> Result, CatalogError> { + let mut q = conn.prepare( + "SELECT DISTINCT f.image_id + FROM faces f + JOIN face_person fp ON fp.face_id = f.id + WHERE fp.person_id = ?1 AND (?2 OR fp.confirmed = 1) + ORDER BY f.image_id", + )?; + let rows = q.query_map(rusqlite::params![person.0 as i64, include_suggested], |r| { + Ok(ImageId(r.get::<_, i64>(0)? as u64)) + })?; + rows.collect::>().map_err(Into::into) +} + +// ── calibration ─────────────────────────────────────────────────────────── + +/// Store a fitted calibration, replacing any previous fit for the model. +pub fn put_calibration( + conn: &Connection, + model_id: &str, + cal: &Calibration, + face_set_hash: &str, +) -> Result<(), CatalogError> { + conn.execute( + "INSERT INTO face_calibration + (model_id, a, b, w_size, valid, positive_pairs, negative_pairs, + face_set_hash, fitted_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(model_id) DO UPDATE SET + a = excluded.a, b = excluded.b, w_size = excluded.w_size, + valid = excluded.valid, + positive_pairs = excluded.positive_pairs, + negative_pairs = excluded.negative_pairs, + face_set_hash = excluded.face_set_hash, + fitted_at = excluded.fitted_at", + rusqlite::params![ + model_id, + cal.a as f64, + cal.b as f64, + cal.w_size as f64, + cal.valid, + cal.positive_pairs as i64, + cal.negative_pairs as i64, + face_set_hash, + now_secs(), + ], + )?; + Ok(()) +} + +/// The calibration for a model, and the face set it was fitted from. +/// +/// Returns `None` when there is no fit at all — distinct from a fit that exists +/// and is [`Calibration::valid`]`== false`, which means it was attempted and +/// there was not enough evidence. The UI says different things about those two. +pub fn calibration( + conn: &Connection, + model_id: &str, +) -> Result, CatalogError> { + conn.query_row( + "SELECT a, b, w_size, valid, positive_pairs, negative_pairs, face_set_hash + FROM face_calibration WHERE model_id = ?1", + [model_id], + |r| { + Ok(( + Calibration { + a: r.get::<_, f64>(0)? as f32, + b: r.get::<_, f64>(1)? as f32, + w_size: r.get::<_, f64>(2)? as f32, + valid: r.get(3)?, + positive_pairs: r.get::<_, i64>(4)? as u64, + negative_pairs: r.get::<_, i64>(5)? as u64, + }, + r.get::<_, String>(6)?, + )) + }, + ) + .optional() + .map_err(Into::into) +} + +// ── the delete-everything control ───────────────────────────────────────── + +/// Delete every face, person, assignment and calibration in the library. +/// +/// NFR-SEC-5 requires this to exist as **one action**, reachable without +/// deleting the catalog or any photograph. It is deliberately not a +/// re-indexing trigger and deliberately not selective: a user asking to remove +/// their face data is not asking to keep some of it. +/// +/// Returns the number of faces removed, so the UI can say what happened rather +/// than claiming success silently. +pub fn delete_all_face_data(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let faces: i64 = tx.query_row("SELECT COUNT(*) FROM faces", [], |r| r.get(0))?; + // Order matters only if foreign keys are off, which they may be on an old + // connection; deleting children first is correct either way. + tx.execute("DELETE FROM face_person_rejected", [])?; + tx.execute("DELETE FROM face_person", [])?; + tx.execute("DELETE FROM faces", [])?; + tx.execute("DELETE FROM people", [])?; + tx.execute("DELETE FROM face_calibration", [])?; + tx.commit()?; + Ok(faces as u64) +} + +// ── helpers ─────────────────────────────────────────────────────────────── + +fn read_face(r: &rusqlite::Row<'_>) -> rusqlite::Result { + let person: Option = r.get(10)?; + Ok(Face { + id: FaceId(r.get::<_, i64>(0)? as u64), + image_id: ImageId(r.get::<_, i64>(1)? as u64), + x: r.get::<_, f64>(2)? as f32, + y: r.get::<_, f64>(3)? as f32, + w: r.get::<_, f64>(4)? as f32, + h: r.get::<_, f64>(5)? as f32, + landmarks: blob_to_landmarks(&r.get::<_, Vec>(6)?), + confidence: r.get::<_, f64>(7)? as f32, + crop_px: r.get::<_, f64>(8)? as f32, + model_id: r.get(9)?, + person: person.map(|p| PersonId(p as u64)), + probability: r.get::<_, Option>(11)?.unwrap_or(0.0) as f32, + confirmed: r.get::<_, Option>(12)?.unwrap_or(false), + }) +} + +fn landmarks_to_blob(lm: &[(f32, f32); 5]) -> Vec { + let mut out = Vec::with_capacity(40); + for &(x, y) in lm { + out.extend_from_slice(&x.to_le_bytes()); + out.extend_from_slice(&y.to_le_bytes()); + } + out +} + +fn blob_to_landmarks(b: &[u8]) -> [(f32, f32); 5] { + let mut out = [(0.0_f32, 0.0_f32); 5]; + for (i, o) in out.iter_mut().enumerate() { + let at = i * 8; + if at + 8 <= b.len() { + let x = f32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); + let y = f32::from_le_bytes([b[at + 4], b[at + 5], b[at + 6], b[at + 7]]); + *o = (x, y); + } + } + out +} + +fn iou(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> f32 { + let ix = (a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0); + let iy = (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1); + if ix <= 0.0 || iy <= 0.0 { + return 0.0; + } + let inter = ix * iy; + let union = a.2 * a.3 + b.2 * b.3 - inter; + if union <= 0.0 { + 0.0 + } else { + inter / union + } +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// A v4 UUID from the system clock and address-space entropy. +/// +/// The same approach `collections` takes: a merge needs identifiers that do not +/// collide across devices, not cryptographic randomness, and this avoids a +/// dependency for one string per person. +fn new_uuid() -> String { + use std::hash::{BuildHasher, Hasher, RandomState}; + let mut h = RandomState::new().build_hasher(); + h.write_u64( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0), + ); + let a = h.finish(); + let mut h2 = RandomState::new().build_hasher(); + h2.write_u64(a); + h2.write_usize(&h2 as *const _ as usize); + let b = h2.finish(); + format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + (a >> 32) as u32, + (a >> 16) as u16, + (a & 0x0fff) as u16, + ((b >> 48) as u16 & 0x3fff) | 0x8000, + b & 0xffff_ffff_ffff, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema; + + fn db() -> Connection { + let c = Connection::open_in_memory().unwrap(); + c.execute_batch("PRAGMA foreign_keys = ON").unwrap(); + schema::migrate(&c).unwrap(); + c + } + + fn image(c: &Connection, n: i64) -> ImageId { + c.execute( + "INSERT OR IGNORE INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", + rusqlite::params![n, format!("IMG_{n}.CR3")], + ) + .unwrap(); + ImageId(n as u64) + } + + fn face(seed: u8) -> DetectedFace { + DetectedFace { + x: 0.1, + y: 0.1, + w: 0.2, + h: 0.3, + landmarks: [(0.1, 0.1), (0.2, 0.1), (0.15, 0.2), (0.12, 0.25), (0.18, 0.25)], + confidence: 0.9, + embedding: vec![seed; 1024], + crop_px: 180.0, + model_id: "w600k_mbf".into(), + } + } + + #[test] + fn migration_reaches_version_eight() { + let c = db(); + let v: i64 = c + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 8); + } + + #[test] + fn detections_round_trip_with_their_landmarks() { + let c = db(); + let img = image(&c, 1); + record_detections(&c, img, &[face(1), face(2)]).unwrap(); + let got = for_image(&c, img).unwrap(); + assert_eq!(got.len(), 2); + assert_eq!(got[0].model_id, "w600k_mbf"); + assert!((got[0].crop_px - 180.0).abs() < 1e-3); + assert!((got[0].landmarks[2].1 - 0.2).abs() < 1e-5); + assert!(got[0].person.is_none()); + } + + /// Re-detection is coalesced per image, so it must replace rather than + /// append — otherwise every re-index doubles the library's face count. + #[test] + fn re_detection_replaces_rather_than_appending() { + let c = db(); + let img = image(&c, 1); + record_detections(&c, img, &[face(1), face(2)]).unwrap(); + record_detections(&c, img, &[face(3)]).unwrap(); + assert_eq!(for_image(&c, img).unwrap().len(), 1); + } + + /// The invariant FR-CULL-10 turns on: a re-index with a better model must + /// not throw away the user's own labelling. + #[test] + fn re_detection_carries_a_confirmation_across() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, ids[0], anna).unwrap(); + + // Same face, box nudged the way a better detector would nudge it. + let mut moved = face(9); + moved.x = 0.11; + moved.y = 0.105; + record_detections(&c, img, &[moved]).unwrap(); + + let got = for_image(&c, img).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].person, Some(anna), "confirmation was lost on re-index"); + assert!(got[0].confirmed); + } + + #[test] + fn a_suggestion_never_overwrites_a_confirmation() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + let bob = create_person(&c, "Bob").unwrap(); + + confirm(&c, ids[0], anna).unwrap(); + let changed = suggest(&c, ids[0], bob, 0.99).unwrap(); + assert!(!changed, "a later pass overwrote a user confirmation"); + assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna)); + } + + #[test] + fn a_rejection_stops_the_face_being_suggested_again() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + + suggest(&c, ids[0], anna, 0.8).unwrap(); + reject(&c, ids[0], anna).unwrap(); + assert_eq!(for_image(&c, img).unwrap()[0].person, None); + + let again = suggest(&c, ids[0], anna, 0.95).unwrap(); + assert!(!again, "clustering re-suggested a face the user pushed away"); + } + + #[test] + fn confirming_overrides_an_earlier_rejection() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + reject(&c, ids[0], anna).unwrap(); + confirm(&c, ids[0], anna).unwrap(); + assert_eq!(for_image(&c, img).unwrap()[0].person, Some(anna)); + } + + #[test] + fn merging_moves_the_faces_and_leaves_a_redirect() { + let c = db(); + let i1 = image(&c, 1); + let i2 = image(&c, 2); + let a = record_detections(&c, i1, &[face(1)]).unwrap(); + let b = record_detections(&c, i2, &[face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + let annie = create_person(&c, "Annie").unwrap(); + confirm(&c, a[0], anna).unwrap(); + confirm(&c, b[0], annie).unwrap(); + + assert_eq!(merge_people(&c, anna, annie).unwrap(), 1); + assert_eq!(for_person(&c, anna, false).unwrap().len(), 2); + assert_eq!(resolve_person(&c, annie).unwrap(), anna); + // The redirect survives so a sync cannot resurrect the merged person. + assert_eq!(people(&c).unwrap().len(), 1); + } + + #[test] + fn merging_does_not_duplicate_a_face_both_people_hold() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1)]).unwrap(); + let a = create_person(&c, "A").unwrap(); + let b = create_person(&c, "B").unwrap(); + confirm(&c, ids[0], a).unwrap(); + // `face_person` is keyed by face, so B can only hold it after A lets go. + unassign(&c, ids[0]).unwrap(); + confirm(&c, ids[0], b).unwrap(); + confirm(&c, ids[0], a).unwrap(); + merge_people(&c, a, b).unwrap(); + assert_eq!(for_person(&c, a, false).unwrap().len(), 1); + } + + #[test] + fn images_for_person_counts_a_photograph_once() { + let c = db(); + let img = image(&c, 1); + // Two faces of the same person in one frame — a mirror, or a collage. + let ids = record_detections(&c, img, &[face(1), face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, ids[0], anna).unwrap(); + confirm(&c, ids[1], anna).unwrap(); + assert_eq!(images_for_person(&c, anna, false).unwrap(), vec![img]); + } + + #[test] + fn suggested_faces_are_excluded_from_a_person_by_default() { + let c = db(); + let i1 = image(&c, 1); + let i2 = image(&c, 2); + let a = record_detections(&c, i1, &[face(1)]).unwrap(); + let b = record_detections(&c, i2, &[face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, a[0], anna).unwrap(); + suggest(&c, b[0], anna, 0.8).unwrap(); + + assert_eq!(for_person(&c, anna, false).unwrap().len(), 1); + assert_eq!(for_person(&c, anna, true).unwrap().len(), 2); + assert_eq!(images_for_person(&c, anna, false).unwrap().len(), 1); + assert_eq!(images_for_person(&c, anna, true).unwrap().len(), 2); + } + + #[test] + fn people_reports_confirmed_and_suggested_separately() { + let c = db(); + let i1 = image(&c, 1); + let i2 = image(&c, 2); + let a = record_detections(&c, i1, &[face(1)]).unwrap(); + let b = record_detections(&c, i2, &[face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, a[0], anna).unwrap(); + suggest(&c, b[0], anna, 0.7).unwrap(); + + let p = people(&c).unwrap(); + assert_eq!(p.len(), 1); + assert_eq!(p[0].confirmed_faces, 1); + assert_eq!(p[0].suggested_faces, 1); + assert_eq!(p[0].name, "Anna"); + } + + #[test] + fn renaming_keeps_the_merge_identity() { + let c = db(); + let anna = create_person(&c, "Anna").unwrap(); + let before = people(&c).unwrap()[0].uuid.clone(); + rename_person(&c, anna, "Anna Smith").unwrap(); + let after = &people(&c).unwrap()[0]; + assert_eq!(after.name, "Anna Smith"); + assert_eq!(after.uuid, before, "a rename must not change identity"); + } + + #[test] + fn deleting_face_data_leaves_the_photographs_alone() { + let c = db(); + let img = image(&c, 1); + let ids = record_detections(&c, img, &[face(1), face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, ids[0], anna).unwrap(); + reject(&c, ids[1], anna).unwrap(); + put_calibration( + &c, + "w600k_mbf", + &Calibration { + a: 16.0, + b: -4.3, + w_size: 0.0, + valid: true, + positive_pairs: 900, + negative_pairs: 90_000, + }, + "hash", + ) + .unwrap(); + + assert_eq!(delete_all_face_data(&c).unwrap(), 2); + assert!(for_image(&c, img).unwrap().is_empty()); + assert!(people(&c).unwrap().is_empty()); + assert!(calibration(&c, "w600k_mbf").unwrap().is_none()); + let images: i64 = c + .query_row("SELECT COUNT(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(images, 1, "deleting face data deleted a photograph"); + } + + #[test] + fn calibration_round_trips_and_its_boundary_inverts_its_probability() { + let c = db(); + let cal = Calibration { + a: 16.2, + b: -4.33, + w_size: 0.0, + valid: true, + positive_pairs: 500, + negative_pairs: 50_000, + }; + put_calibration(&c, "w600k_mbf", &cal, "abc").unwrap(); + let (got, hash) = calibration(&c, "w600k_mbf").unwrap().unwrap(); + assert_eq!(hash, "abc"); + assert!((got.a - 16.2).abs() < 1e-4); + assert!(got.valid); + + for &p in &[0.5_f32, 0.9, 0.99] { + let cos = got.boundary_at(p, 112.0, 0.0); + let back = got.probability(cos, 112.0, 0.0); + assert!((back - p).abs() < 1e-3, "p={p} round-tripped to {back}"); + } + } + + /// The reference implementation's fitted MBF curve puts the P=0.5 boundary + /// at cosine 0.267 (docs/faces.md §1). Our own first end-to-end run scored + /// 0.596 between distinct photographs of one person and 0.05 between + /// different people, so those two must land either side. + #[test] + fn the_reference_calibration_separates_the_measured_cosines() { + let cal = Calibration { + a: 16.2, + b: -16.2 * 0.267, + w_size: 0.0, + valid: true, + positive_pairs: 0, + negative_pairs: 0, + }; + assert!(cal.probability(0.596, 200.0, 0.0) > 0.99); + assert!(cal.probability(0.050, 200.0, 0.0) < 0.05); + assert!((cal.boundary_at(0.5, 200.0, 0.0) - 0.267).abs() < 1e-3); + } + + #[test] + fn unassigned_lists_only_faces_with_no_identity_and_the_right_model() { + let c = db(); + let i1 = image(&c, 1); + let i2 = image(&c, 2); + let a = record_detections(&c, i1, &[face(1)]).unwrap(); + record_detections(&c, i2, &[face(2)]).unwrap(); + let anna = create_person(&c, "Anna").unwrap(); + confirm(&c, a[0], anna).unwrap(); + + assert_eq!(unassigned(&c, "w600k_mbf").unwrap().len(), 1); + assert!(unassigned(&c, "some_other_model").unwrap().is_empty()); + } + + #[test] + fn embeddings_come_back_as_stored() { + let c = db(); + let img = image(&c, 1); + record_detections(&c, img, &[face(7)]).unwrap(); + let e = embeddings(&c, "w600k_mbf").unwrap(); + assert_eq!(e.len(), 1); + assert_eq!(e[0].1, img); + assert_eq!(e[0].2.len(), 1024); + assert_eq!(e[0].2[0], 7); + assert!((e[0].3 - 180.0).abs() < 1e-3); + } +} diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 197c6ec..45e69e0 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -15,6 +15,7 @@ //! - [`query`] — selectors compiled to indexed SQL, windowed for the grid //! - [`collections`] — the collection tree and membership the UI edits //! - [`keywords`] — the keyword vocabulary and what it is assigned to +//! - [`faces`] — detected faces, the people they belong to, and who said so //! - [`jobs`] — the durable background work queue //! - [`trash`] — soft delete to a folder, then permanent delete //! - [`merge`] / [`sync`] — cross-device merging of collections and keywords @@ -36,6 +37,7 @@ pub mod cache; pub mod collections; pub mod dedup; pub mod error; +pub mod faces; pub mod jobs; pub mod keywords; pub mod merge; @@ -51,6 +53,7 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use collections::{Collection, CollectionKind, TreeRow}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; pub use error::CatalogError; +pub use faces::{Calibration, DetectedFace, Face, FaceId, Person, PersonId}; pub use jobs::{Job, JobKind, Priority}; pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword}; pub use merge::MergeReport; diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index 5d942ef..a3efc1b 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 7; +pub const SCHEMA_VERSION: i64 = 8; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -78,6 +78,12 @@ pub fn migrate(conn: &Connection) -> Result { tx.pragma_update(None, "user_version", 7)?; tx.commit()?; } + if from < 8 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V8)?; + tx.pragma_update(None, "user_version", 8)?; + tx.commit()?; + } Ok(from) } @@ -327,6 +333,114 @@ fn stem_of(path: &str) -> &str { /// Both narrow the walk rather than reorder it, so the index still supplies the /// ordering and SQLite tests the extra predicate per row. That is the cheap /// direction: the expensive part was never the filtering, it was the sort. +const V8: &str = r#" +-- TRACES: FR-CULL-8 | FR-CULL-9 | FR-CULL-10 | FR-CULL-11 | FR-CULL-12 | NFR-SEC-5 +-- People and faces (docs/faces.md, docs/catalog.md §10). +-- +-- Everything here is **derived data** except one column. Faces, landmarks, +-- embeddings, cluster assignments and suggestions are all reproducible by +-- re-indexing and are never written to a sidecar (FR-CULL-12); a person's +-- *name*, once the user has confirmed it, is a human judgement of the same +-- class as a rating and travels with the photograph. +-- +-- That asymmetry is the whole design: deleting the catalog costs an afternoon +-- of re-indexing and loses nothing the user typed (ARCH §6.12). + +CREATE TABLE people ( + id INTEGER PRIMARY KEY, + -- Merge identity, not the name. Two devices that independently name the + -- same cluster produce two people; merging them keys on this, exactly as + -- collections do (FR-CAT-7, ARCH §6.3). + uuid TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + -- Tombstone-by-redirect. A merged person must outlive its merge or a + -- device that still holds it resurrects it on the next sync -- the same + -- hazard collections have, solved the same way. + merged_into INTEGER REFERENCES people(id) ON DELETE SET NULL, + created INTEGER NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + modified INTEGER NOT NULL +); + +CREATE TABLE faces ( + id INTEGER PRIMARY KEY, + image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + -- Normalised to the image's long edge, so a face survives the proxy it was + -- found on being evicted and regenerated at another resolution. Storing + -- pixels would bind a face to a resolution the cache is entitled to change. + x REAL NOT NULL, y REAL NOT NULL, w REAL NOT NULL, h REAL NOT NULL, + landmarks BLOB NOT NULL, -- 5 x (x, y) f32, normalised likewise + detector_confidence REAL NOT NULL, + embedding BLOB NOT NULL, -- 512 x f16, L2-normalised + -- Source pixels across the aligned 112x112 crop (docs/faces.md §7). + -- + -- Not cosmetic: it is the honest quality signal for the UI, a feature in + -- the §8 calibration -- FR-CULL-9 names face size as an axis along which an + -- uncalibrated similarity misbehaves -- and the selector a later + -- higher-resolution re-embedding pass would run on. + crop_px REAL NOT NULL, + -- Which model produced this embedding. + -- + -- The one mistake in this subsystem that yields plausible-looking garbage + -- rather than an error: embeddings from different models are not + -- comparable. Storing the model with the vector makes a model change + -- detectable and re-indexable instead of quietly poisoning every + -- similarity in the library. + model_id TEXT NOT NULL, + detected_at INTEGER NOT NULL +); +CREATE INDEX faces_image ON faces(image_id); +CREATE INDEX faces_model ON faces(model_id); + +CREATE TABLE face_person ( + face_id INTEGER PRIMARY KEY REFERENCES faces(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + -- Calibrated P(this face is this person), never a raw cosine (FR-CULL-9). + probability REAL NOT NULL, + -- The user said so. Never overwritten by a later inference pass. + -- + -- A column rather than a probability of 1.0, because a confirmation is a + -- different kind of fact from a confident guess and collapsing them loses + -- the ability to recompute suggestions without touching user data. + confirmed INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX face_person_person ON face_person(person_id, confirmed); + +-- Faces the user has explicitly said are NOT a given person. +-- +-- Needed because rejection is not the absence of an assignment: without it, +-- the next clustering pass re-suggests exactly the face the user just pushed +-- away, and the tool feels broken. Same reasoning as `confirmed` -- a +-- judgement is user data (FR-CULL-12) whichever direction it points. +CREATE TABLE face_person_rejected ( + face_id INTEGER NOT NULL REFERENCES faces(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE, + PRIMARY KEY (face_id, person_id) +); + +-- The FR-CULL-9 calibration, fitted from this library's own faces. +-- +-- One row per model, because the fit is a property of the embedding space and +-- a library indexed across a model change holds two. `face_set_hash` is what +-- makes a stale fit detectable: a materially changed library recomputes rather +-- than trusting numbers derived from a set that no longer exists. +CREATE TABLE face_calibration ( + model_id TEXT PRIMARY KEY, + -- P(same) = sigmoid(a*cos + b + w_size*log2(min(crop_px)) + log_prior_odds) + a REAL NOT NULL, + b REAL NOT NULL, + w_size REAL NOT NULL DEFAULT 0.0, + -- Whether the fit is usable at all. When it is not, the UI says the + -- confidence is unavailable; it does not present an untuned default as + -- though it were measured (FR-CULL-9). + valid INTEGER NOT NULL DEFAULT 0, + positive_pairs INTEGER NOT NULL DEFAULT 0, + negative_pairs INTEGER NOT NULL DEFAULT 0, + face_set_hash TEXT NOT NULL, + fitted_at INTEGER NOT NULL +); +"#; + const V7: &str = r#" CREATE INDEX images_grid_order ON images(captured_at IS NULL, captured_at, source_ref) diff --git a/docs/traceability.md b/docs/traceability.md index ee9b077..c5a123f 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 234 | -| TRACES tags found | 621 | +| Source files scanned | 235 | +| TRACES tags found | 623 | | Requirements defined | 177 | -| Requirements covered | 91 | -| **Coverage** | **51.4%** (91/177) | +| Requirements covered | 97 | +| **Coverage** | **54.8%** (97/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 72 | 122 | -| NFR | 17 | 49 | +| FR | 77 | 122 | +| NFR | 18 | 49 | | R | 2 | 6 | ## Orphan tags @@ -38,19 +38,24 @@ _None._ | 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:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2122`](../ui/dr-ui/src/library.rs#L2122), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:422`](../core/dr-catalog/src/schema.rs#L422), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3007`](../ui/dr-ui/src/library.rs#L3007), [`ui/dr-ui/src/library.rs:3041`](../ui/dr-ui/src/library.rs#L3041), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:536`](../core/dr-catalog/src/schema.rs#L536), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3007`](../ui/dr-ui/src/library.rs#L3007), [`ui/dr-ui/src/library.rs:3041`](../ui/dr-ui/src/library.rs#L3041), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3608`](../ui/dr-ui/src/library_ui.rs#L3608), [`ui/dr-ui/src/library_ui.rs:4574`](../ui/dr-ui/src/library_ui.rs#L4574), [`ui/dr-ui/ui/app.slint:513`](../ui/dr-ui/ui/app.slint#L513), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | -| 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:292`](../core/dr-catalog/src/schema.rs#L292), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| 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/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:840`](../core/dr-catalog/src/schema.rs#L840), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3055`](../ui/dr-ui/src/library.rs#L3055), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6266`](../ui/dr-ui/src/library_ui.rs#L6266), [`ui/dr-ui/src/library_ui.rs:6279`](../ui/dr-ui/src/library_ui.rs#L6279), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6294`](../ui/dr-ui/src/library_ui.rs#L6294), [`ui/dr-ui/src/library_ui.rs:6303`](../ui/dr-ui/src/library_ui.rs#L6303), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1212`](../ui/dr-ui/ui/library.slint#L1212), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) | -| 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/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`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.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3301`](../ui/dr-ui/src/library.rs#L3301), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5124`](../ui/dr-ui/src/library_ui.rs#L5124), [`ui/dr-ui/src/library_ui.rs:5177`](../ui/dr-ui/src/library_ui.rs#L5177), [`ui/dr-ui/src/library_ui.rs:6395`](../ui/dr-ui/src/library_ui.rs#L6395), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:826`](../ui/dr-ui/ui/app.slint#L826), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1278`](../ui/dr-ui/ui/library.slint#L1278), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901), [`ui/dr-ui/ui/settings.slint:115`](../ui/dr-ui/ui/settings.slint#L115) | +| 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:298`](../core/dr-catalog/src/schema.rs#L298), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | +| 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/schema.rs:451`](../core/dr-catalog/src/schema.rs#L451), [`core/dr-catalog/src/schema.rs:954`](../core/dr-catalog/src/schema.rs#L954), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3055`](../ui/dr-ui/src/library.rs#L3055), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6266`](../ui/dr-ui/src/library_ui.rs#L6266), [`ui/dr-ui/src/library_ui.rs:6279`](../ui/dr-ui/src/library_ui.rs#L6279), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6294`](../ui/dr-ui/src/library_ui.rs#L6294), [`ui/dr-ui/src/library_ui.rs:6303`](../ui/dr-ui/src/library_ui.rs#L6303), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1212`](../ui/dr-ui/ui/library.slint#L1212), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901) | +| 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/schema.rs:451`](../core/dr-catalog/src/schema.rs#L451), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`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.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3301`](../ui/dr-ui/src/library.rs#L3301), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5124`](../ui/dr-ui/src/library_ui.rs#L5124), [`ui/dr-ui/src/library_ui.rs:5177`](../ui/dr-ui/src/library_ui.rs#L5177), [`ui/dr-ui/src/library_ui.rs:6395`](../ui/dr-ui/src/library_ui.rs#L6395), [`ui/dr-ui/ui/app.slint:461`](../ui/dr-ui/ui/app.slint#L461), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:826`](../ui/dr-ui/ui/app.slint#L826), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1278`](../ui/dr-ui/ui/library.slint#L1278), [`ui/dr-ui/ui/library.slint:901`](../ui/dr-ui/ui/library.slint#L901), [`ui/dr-ui/ui/settings.slint:115`](../ui/dr-ui/ui/settings.slint#L115) | | FR-CAT-7 | [`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.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3466`](../ui/dr-ui/src/library_ui.rs#L3466), [`ui/dr-ui/src/library_ui.rs:4164`](../ui/dr-ui/src/library_ui.rs#L4164), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2516`](../ui/dr-ui/ui/library.slint#L2516), [`ui/dr-ui/ui/library.slint:872`](../ui/dr-ui/ui/library.slint#L872), [`ui/dr-ui/ui/library.slint:891`](../ui/dr-ui/ui/library.slint#L891) | | FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:2790`](../ui/dr-ui/src/develop.rs#L2790), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/lib.rs:1684`](../ui/dr-ui/src/lib.rs#L1684), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:849`](../ui/dr-ui/src/lib.rs#L849), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4866`](../ui/dr-ui/src/library_ui.rs#L4866), [`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:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3408`](../ui/dr-ui/src/library.rs#L3408), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3329`](../ui/dr-ui/src/library_ui.rs#L3329), [`ui/dr-ui/src/library_ui.rs:3517`](../ui/dr-ui/src/library_ui.rs#L3517), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5222`](../ui/dr-ui/src/library_ui.rs#L5222), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`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:508`](../core/dr-catalog/src/schema.rs#L508), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3408`](../ui/dr-ui/src/library.rs#L3408), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3329`](../ui/dr-ui/src/library_ui.rs#L3329), [`ui/dr-ui/src/library_ui.rs:3517`](../ui/dr-ui/src/library_ui.rs#L3517), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5222`](../ui/dr-ui/src/library_ui.rs#L5222), [`ui/dr-ui/src/library_ui.rs:5337`](../ui/dr-ui/src/library_ui.rs#L5337), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | +| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | +| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | +| FR-CULL-8 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | +| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | | FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:154`](../core/dr-pipeline/src/graph.rs#L154), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3194`](../ui/dr-ui/src/develop.rs#L3194), [`ui/dr-ui/src/develop.rs:3248`](../ui/dr-ui/src/develop.rs#L3248), [`ui/dr-ui/src/develop.rs:3292`](../ui/dr-ui/src/develop.rs#L3292), [`ui/dr-ui/src/develop.rs:3342`](../ui/dr-ui/src/develop.rs#L3342), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) | @@ -84,14 +89,14 @@ _None._ | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-catalog/src/schema.rs:795`](../core/dr-catalog/src/schema.rs#L795), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1610`](../ui/dr-ui/src/lib.rs#L1610), [`ui/dr-ui/src/lib.rs:2407`](../ui/dr-ui/src/lib.rs#L2407), [`ui/dr-ui/src/library.rs:1324`](../ui/dr-ui/src/library.rs#L1324), [`ui/dr-ui/src/library.rs:1347`](../ui/dr-ui/src/library.rs#L1347), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5239`](../ui/dr-ui/src/library_ui.rs#L5239), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5269`](../ui/dr-ui/src/library_ui.rs#L5269), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2420`](../ui/dr-ui/ui/app.slint#L2420), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:973`](../ui/dr-ui/ui/library.slint#L973) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:508`](../core/dr-catalog/src/schema.rs#L508), [`core/dr-catalog/src/schema.rs:909`](../core/dr-catalog/src/schema.rs#L909), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1610`](../ui/dr-ui/src/lib.rs#L1610), [`ui/dr-ui/src/lib.rs:2407`](../ui/dr-ui/src/lib.rs#L2407), [`ui/dr-ui/src/library.rs:1324`](../ui/dr-ui/src/library.rs#L1324), [`ui/dr-ui/src/library.rs:1347`](../ui/dr-ui/src/library.rs#L1347), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5239`](../ui/dr-ui/src/library_ui.rs#L5239), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5269`](../ui/dr-ui/src/library_ui.rs#L5269), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2420`](../ui/dr-ui/ui/app.slint#L2420), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:973`](../ui/dr-ui/ui/library.slint#L973) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | | FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | | FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:3608`](../ui/dr-ui/src/library_ui.rs#L3608), [`ui/dr-ui/ui/settings.slint:340`](../ui/dr-ui/ui/settings.slint#L340) | | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002) | | FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:451`](../core/dr-catalog/src/schema.rs#L451), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:789`](../ui/dr-ui/src/lib.rs#L789), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -111,7 +116,7 @@ _None._ | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:292`](../core/dr-catalog/src/schema.rs#L292), [`ui/dr-ui/src/library.rs:3141`](../ui/dr-ui/src/library.rs#L3141), [`ui/dr-ui/src/library.rs:3914`](../ui/dr-ui/src/library.rs#L3914), [`ui/dr-ui/src/library_ui.rs:4054`](../ui/dr-ui/src/library_ui.rs#L4054), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:298`](../core/dr-catalog/src/schema.rs#L298), [`ui/dr-ui/src/library.rs:3141`](../ui/dr-ui/src/library.rs#L3141), [`ui/dr-ui/src/library.rs:3914`](../ui/dr-ui/src/library.rs#L3914), [`ui/dr-ui/src/library_ui.rs:4054`](../ui/dr-ui/src/library_ui.rs#L4054), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:947`](../ui/dr-ui/src/library.rs#L947) | @@ -120,27 +125,23 @@ _None._ | 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-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:394`](../core/dr-catalog/src/schema.rs#L394), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:508`](../core/dr-catalog/src/schema.rs#L508), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | +| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | | R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | ## Not yet tagged -86 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +80 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-14 -- FR-CULL-10 -- FR-CULL-11 -- FR-CULL-12 - FR-CULL-3 - FR-CULL-5 - FR-CULL-6 - FR-CULL-7 -- FR-CULL-8 -- FR-CULL-9 - FR-DEV-1 - FR-DEV-3g - FR-DEV-7 @@ -211,7 +212,6 @@ _None._ - NFR-SEC-2 - NFR-SEC-3 - NFR-SEC-4 -- NFR-SEC-5 - NFR-SEC-6 - R2 - R3