diff --git a/.gitea/workflows/benchmark.yml b/.gitea/workflows/benchmark.yml new file mode 100644 index 0000000..782a927 --- /dev/null +++ b/.gitea/workflows/benchmark.yml @@ -0,0 +1,193 @@ +name: Benchmarks + +# The suite docs/requirements.md §8 has been promising since it was written: +# "an automated benchmark suite against a synthetic 50k catalog, run per-commit +# … A regression beyond stated tolerance fails the build." +# +# Its own workflow rather than a step inside build-and-test.yml, and the reason +# is what a failure here means. A red `Build and test` says the code is wrong; a +# red `Benchmarks` says the code is slower than it was, which is a different +# conversation, is read by different people, and must not be reachable by +# retrying a flaky compile. +# +# # Why this is split in two +# +# §8 names "the reference desktop", not CI, and it is right to. So: +# +# cpu — runs on every push. It needs no adapter and no display, and the +# budgets it asserts (a 50k catalog opening inside two seconds) have +# two orders of magnitude of headroom, so a modest runner can be held +# to them honestly. Machine-sensitive budgets — throughput targets +# written for a 24-thread desktop — are reported here rather than +# asserted; `dr-bench` decides that per metric and says so in its +# report. Asserting them on a two-core container would produce exactly +# what core/dr-gpu/tests/frame_budget.rs refused to produce: a red gate +# everybody learns to ignore. +# +# gpu — the frame budget, which already exists and already skips itself where +# there is no adapter. Not on push: it would build wgpu and naga on +# every commit to establish, every time, that this runner has no GPU. It +# runs on demand (Actions → Run workflow) so that a runner that *does* +# have one can be pointed at it, and the numbers it produces belong in +# docs/frame-budget.md by hand, as they already are. + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + +jobs: + cpu: + runs-on: linux/amd64 + name: CPU and I/O (per commit) + # Node for actions/checkout and actions/cache, which the bare runner image + # cannot execute. Rust is installed below. + container: + image: catthehacker/ubuntu:act-latest + + env: + # Same reasoning as the desktop job in build-and-test.yml: incremental + # state exists to make the *second* build in a working tree fast, which is + # not a thing a fresh checkout has, and it fills the runner's disk. + CARGO_INCREMENTAL: 0 + # The fixture, out of the workspace so actions/cache never picks it up. + # A 14 MB synthetic catalog is two seconds to regenerate and would + # otherwise be uploaded and downloaded on every push to save them. + DR_BENCH_DIR: /tmp/darkroom-bench + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # No `git lfs pull` here, deliberately. `dr-bench` depends on the catalog, + # the decoder, the thumbnail store and the encoder, and on nothing that + # reaches `dr-segment` — so the model this repository keeps in LFS is not + # part of this job's dependency graph and fetching it would be a minute + # spent on a file nothing opens. + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: bench-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + # Pinned to the workspace rust-version, as every other job here is: a + # floating toolchain turns an unrelated push into a mystery failure, and + # for a benchmark it would turn one into a mystery *regression*. + # + # rust-analyzer is named for the reason build-and-test.yml gives: rustup + # reconciles rust-toolchain.toml on the first cargo call whatever this + # step asks for, so naming it keeps the download inside the step that says + # it is installing things. + - name: Install Rust 1.92.0 + run: | + set -e + curl -fsSL https://sh.rustup.rs | sh -s -- \ + -y --no-modify-path --profile minimal --default-toolchain 1.92.0 \ + --component rust-analyzer + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # `-p dr-bench`, not `--workspace`. The whole point of that crate having + # no GPU and no UI dependency is that this job resolves the catalog, the + # decoder and the encoders and stops there — a few minutes rather than the + # release build of Slint and wgpu the desktop job pays for. + # + # Release, and it is not optional: the workspace builds its own crates at + # opt-level = 0 in dev, and every figure this produces is dominated by + # this workspace's own code. A debug run would measure rustc. + - name: Build the suite + run: cargo build --release -p dr-bench + + # Exit 1 is a violated budget or a regression past tolerance; exit 2 is + # the harness failing to run at all. Both fail the job, and the report + # above the failure says which. + - name: Measure, and gate + run: cargo run --release -p dr-bench -- check + + - name: Disk after + if: always() + run: df -h /workspace 2>/dev/null || df -h . + + gpu: + # On demand only — see the header. A runner with a Vulkan device can be + # pointed at this; one without will skip the measurement and say so, which + # is the same posture the rest of this repository's device tests take. + if: github.event_name == 'workflow_dispatch' + runs-on: linux/amd64 + name: Frame budget (on demand) + container: + image: catthehacker/ubuntu:act-latest + + env: + CARGO_INCREMENTAL: 0 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + # `dr-gpu` depends on `dr-segment` for the watershed's pixel passes. Its + # default features are off, so no weights are compiled in — but the fetch + # is cheap insurance and its failure is not fatal. The header of the same + # step in build-and-test.yml explains why the extraheader is stripped + # rather than reused: two Authorization headers is a 400 from Gitea, one + # step after the batch call that had just succeeded. + - name: Fetch the segmentation model + continue-on-error: true + env: + LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }} + run: | + set -e + git lfs install --local + git config --local --get-regexp '^http\..*extraheader$' \ + | cut -d' ' -f1 | sort -u \ + | while read -r key; do git config --local --unset-all "$key"; done || true + git config --local lfs.url \ + "https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs" + git lfs pull + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: bench-gpu-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Build dependencies + run: | + apt-get update -qq + apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev + + - name: Install Rust 1.92.0 + run: | + set -e + curl -fsSL https://sh.rustup.rs | sh -s -- \ + -y --no-modify-path --profile minimal --default-toolchain 1.92.0 \ + --component rust-analyzer + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + # The guard, in release. Its own module documentation is explicit that a + # release run checks strictly more than a dev one: the CPU half of a frame + # is shader-string assembly, which is several times slower unoptimised, so + # it is folded into the assertion only when debug_assertions is off. + # + # With no adapter this prints "skipping: no GPU adapter" and passes. A + # test that cannot run is not evidence either way, and turning that into a + # failure would make the job useless on the runner it usually lands on. + - name: Frame budget (FR-DSP-3) + run: cargo test --release -p dr-gpu --test frame_budget -- --nocapture + + # The instrument behind docs/frame-budget.md. It exits non-zero with no + # adapter, which is right for a tool a person runs deliberately and wrong + # for a job that usually has none — hence continue-on-error. Its table is + # in the log for whoever asked for this run; the committed numbers are + # still updated by hand, as that file says. + - name: Frame budget table + continue-on-error: true + run: cargo run --release -p dr-gpu --example frame_budget diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c554e9..c730269 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,21 @@ GPU tests skip themselves where there is no adapter rather than failing — a test that cannot run is not evidence either way — so a green run on a machine without a GPU is expected, and does not mean the GPU paths were exercised. +There is a fifth check, and it is not in that list because you are unlikely to +break it by accident: + +```bash +cargo run --release -p dr-bench -- check +``` + +That is the benchmark suite (`docs/requirements.md` §8), which builds a +synthetic 50,000-image catalog and fails the build if a performance target is +missed or a measurement has drifted past its tolerance. It runs on every push in +its own workflow. [`docs/benchmarks.md`](docs/benchmarks.md) says what it +measures, what it deliberately does not, and how to read a failure. If you have +touched the catalog, the decoder, the thumbnail store or the exporter, run it +before you send. + ## Requirements and traceability [`requirements.md`](docs/requirements.md) is the register of record. @@ -150,6 +165,7 @@ One commit per change. If you fixed two things, that is two commits. | [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless | | [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync | | [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs | +| [`docs/benchmarks.md`](docs/benchmarks.md) | A change that could plausibly cost time or memory | | [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen | | [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one | | [`docs/requirements.md`](docs/requirements.md) | Reference, not reading | diff --git a/Cargo.lock b/Cargo.lock index 57645f1..e5eb511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1224,6 +1224,7 @@ name = "darkroom-android" version = "0.9.0" dependencies = [ "android_logger", + "dr-plat", "dr-sync", "dr-ui", "jni 0.21.1", @@ -1236,6 +1237,7 @@ name = "darkroom-desktop" version = "0.9.0" dependencies = [ "anyhow", + "dr-plat", "dr-ui", "env_logger", "log", @@ -1403,6 +1405,23 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dr-bench" +version = "0.9.0" +dependencies = [ + "anyhow", + "dr-catalog", + "dr-decode", + "dr-export", + "dr-thumbs", + "dr-types", + "env_logger", + "log", + "rusqlite", + "serde", + "serde_json", +] + [[package]] name = "dr-catalog" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 1a57353..875ca97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "ui/dr-ui", "apps/darkroom-desktop", "apps/darkroom-android", + "tools/bench", "tools/traceability", ] diff --git a/apps/darkroom-android/Cargo.toml b/apps/darkroom-android/Cargo.toml index 5699149..82af380 100644 --- a/apps/darkroom-android/Cargo.toml +++ b/apps/darkroom-android/Cargo.toml @@ -19,6 +19,11 @@ dr-ui.workspace = true # For `account::set_data_dir`: only the platform entry point knows where Android # lets this app keep files, and it must be set before any store is opened. dr-sync.workspace = true +# For the panic hook, for `state::set_state_dir` and for +# `diagnostics::install`. Android has no XDG directories, so the entry point is +# the only place that knows where a crash record or a log file may be written, +# and both have to be in place before anything can fail. +dr-plat.workspace = true # Directly, not just through dr-ui: `android_main` takes an `AndroidApp` and # calls `slint::android::init`, both of which come from this crate. The backend # feature comes from dr-ui's target-specific dependency. diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index ac0b0ec..7a18205 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -6,8 +6,10 @@ //! * There are no command-line paths. Android's SAF hands out document URIs, //! not filesystem paths (ARCH §6.9), so the viewer opens with an empty //! browsing list and the library grid is the only way in. -//! * Logging goes to logcat. `env_logger` writes to stderr, which Android -//! discards. +//! * Logging goes to logcat *and* to a file. `env_logger` writes to stderr, +//! which Android discards; logcat replaces it, and a rotating file beside it +//! replaces the thing logcat cannot be — a record that outlives the session +//! and can be sent to somebody (NFR-OPS-1, [`dr_plat::diagnostics`]). //! //! The first of those has one exception, and it is the launch `Intent`: a //! gallery, a file manager or the share sheet can name images to open, and @@ -29,22 +31,78 @@ mod intents; /// Android application entry point, called by android-activity's glue. #[no_mangle] fn android_main(app: slint::android::AndroidApp) { - android_logger::init_once( + // Before the logger, because the logger needs somewhere to write, and + // before any `log::` call at all, because records emitted before this line + // reach nothing. + // + // **The external directory, not the internal one, and the difference is + // the entire point of the file.** Both are app-private and both survive + // backgrounding — the volatile one is the *cache* directory, which is not + // in play here. What separates them is retrieval: + // `/data/data//files` needs `run-as` against a debuggable build or + // root to read, and `/sdcard/Android/data//files` is a plain + // `adb pull` from any build, needing no permission since API 19. A log + // nobody can get off the device does not do the job NFR-OPS-1 describes. + // + // The consequence is that anyone holding the tablet can read it, which is + // why `dr_plat::diagnostics` redacts at the sink and why configuration — + // the account list, and the credential reference beside it — stays on + // `internal_data_path` below rather than moving here (NFR-SEC-2). + let external = app.external_data_path(); + if let Some(dir) = external.clone().or_else(|| app.internal_data_path()) { + dr_plat::set_state_dir(dir); + } + + // `AndroidLogger` rather than `init_once`, so logcat can be *teed* rather + // than replaced: `init_once` installs itself as the global logger and + // there is only one of those. Everything that reached logcat before this + // change still reaches it, at the same level and under the same tag; the + // file is strictly additional. + let console = android_logger::AndroidLogger::new( android_logger::Config::default() .with_max_level(log::LevelFilter::Info) .with_tag("DarkRoom"), ); + let logging = dr_plat::diagnostics::install(Box::new(console), log::LevelFilter::Info); // Panics go to stderr, and Android discards stderr. Without this hook a // worker thread that panics is invisible: the process survives, the // channel it was writing to closes, and the UI reports only that // something "failed unexpectedly" with no way to find out what. - std::panic::set_hook(Box::new(|info| { - log::error!("panic: {info}"); - })); + // + // This used to be one `log::error!` of the raw panic, which had two + // problems: logcat is a ring buffer that is gone by the time a user + // reports anything, and the raw message can carry a document URI naming + // their library or a credential a library interpolated into an error + // (NFR-SEC-2). `dr_plat::crash` writes a redacted record to disk and logs + // the redacted form. Nothing uploads it. + // + // Before `set_state_dir` on purpose: the hook resolves the directory when + // it fires, so installing it first covers the startup below rather than + // leaving it uncovered. + dr_plat::crash::install(env!("CARGO_PKG_VERSION")); log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION")); + // Said in logcat as well as in the file, because the first thing anybody + // asked for a log needs is where it is — and on a device that is a path + // nobody can guess and a command nobody remembers. + match &logging { + dr_plat::Installed::ToFile(path) => { + log::info!("logging to {}", path.display()); + if external.is_none() { + log::warn!( + "no external storage; the log is app-private and needs \ + `adb shell run-as paris.tourolle.darkroom cat files/darkroom.log` \ + on a debuggable build" + ); + } + } + dr_plat::Installed::ConsoleOnly(why) => { + log::warn!("no log file this session, only logcat: {why}"); + } + } + // Before anything opens a store: Android has no $HOME and no XDG // directories, so the default guess resolves to a path the app cannot // write. Nothing failed loudly — the session list went to a doomed path, so @@ -54,6 +112,10 @@ fn android_main(app: slint::android::AndroidApp) { match app.internal_data_path() { Some(dir) => { log::info!("data dir: {}", dir.display()); + // Crash records go beside the account data rather than under it: + // both are app-private and neither is a cache, which is the whole + // distinction that matters here (see `dr_plat::crash::state_dir`). + dr_plat::crash::set_state_dir(dir.join("state")); dr_sync::account::set_data_dir(dir); } None => log::error!("no internal data path; settings will not persist"), diff --git a/apps/darkroom-desktop/Cargo.toml b/apps/darkroom-desktop/Cargo.toml index 238b7f6..48f4a7f 100644 --- a/apps/darkroom-desktop/Cargo.toml +++ b/apps/darkroom-desktop/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true [dependencies] dr-ui.workspace = true +# For the panic hook and the log sink, directly rather than through dr-ui: +# both have to be installed before `dr_ui::run`, because a panic during startup +# is exactly the one they exist to catch and record (NFR-OPS-1, NFR-OPS-2). +dr-plat.workspace = true anyhow.workspace = true env_logger.workspace = true log.workspace = true diff --git a/apps/darkroom-desktop/src/main.rs b/apps/darkroom-desktop/src/main.rs index 05713c8..a418e42 100644 --- a/apps/darkroom-desktop/src/main.rs +++ b/apps/darkroom-desktop/src/main.rs @@ -4,13 +4,37 @@ use std::path::PathBuf; +use dr_plat::diagnostics::Installed; + fn main() -> anyhow::Result<()> { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( + // Built rather than `init`ed, so the same logger can be handed to the + // diagnostics tee: `env_logger` keeps writing to stderr exactly as before, + // and every record it accepts is also appended to the on-disk log + // (NFR-OPS-1). `filter()` is asked afterwards because the environment may + // have overridden the default below, and the file must not be quieter than + // the terminal. + let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or( "info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn", )) - .init(); + .build(); + let level = console.filter(); + let logging = dr_plat::diagnostics::install(Box::new(console), level); + + // Immediately after the logger and before anything that could fail. Until + // now a panic on desktop went to stderr and died with the terminal, which + // means every panic a user has ever hit was unreportable: the process + // survives (the panicking worker does not), a control goes dead, and there + // is nothing on disk to say why. The record is local and stays local — + // there is no upload path, by design; see `dr_plat::crash`. + dr_plat::crash::install(env!("CARGO_PKG_VERSION")); log::info!("DarkRoom v{}", env!("CARGO_PKG_VERSION")); + // First thing in the file, so a user asked for "the log" can find it + // without being told a path over the phone. + match &logging { + Installed::ToFile(path) => log::info!("logging to {}", path.display()), + Installed::ConsoleOnly(why) => log::warn!("no log file this session: {why}"), + } let paths: Vec = std::env::args().skip(1).map(PathBuf::from).collect(); if paths.is_empty() { diff --git a/core/dr-catalog/src/error.rs b/core/dr-catalog/src/error.rs index 0a8f0da..e88ec7f 100644 --- a/core/dr-catalog/src/error.rs +++ b/core/dr-catalog/src/error.rs @@ -1,14 +1,37 @@ -//! TRACES: NFR-ARCH-4 | NFR-R5 +//! TRACES: NFR-ARCH-4 | NFR-R5 | NFR-R6 //! Catalog errors. //! //! Typed and attached to the affected subject rather than panicking — a //! corrupt row or a failed job marks one image and lets the batch continue. +//! +//! # Why `From` is written by hand +//! +//! One class of SQLite failure is not about the statement that hit it: when +//! the file itself is damaged, *every* query fails, and which one the user +//! happened to trigger first says nothing. Before this, corruption reached the +//! interface as whatever `Sqlite(...)` the first failing query produced — +//! "database disk image is malformed" attached to a thumbnail refresh — and +//! there was nowhere to hang a recovery offer. +//! +//! So the conversion classifies rather than wraps: `SQLITE_CORRUPT` and +//! `SQLITE_NOTADB` become [`CatalogError::Corrupt`] wherever they arise, which +//! means a background job that trips over the damage reports the same thing +//! the startup check does (see [`crate::recovery`]). /// Something went wrong talking to the catalog. #[derive(Debug, thiserror::Error)] pub enum CatalogError { #[error("sqlite: {0}")] - Sqlite(#[from] rusqlite::Error), + Sqlite(#[source] rusqlite::Error), + + /// The catalog file is damaged. + /// + /// Its own variant because it is the one error with a *user-facing + /// remedy*: restore the NFR-R2 backup, or discard the index and rebuild it + /// from sources plus sidecars (NFR-R6, invariant §5.2.4). Every other + /// variant here is either a caller's mistake or a fact about one row. + #[error("the catalog file is damaged: {detail}")] + Corrupt { detail: String }, /// The catalog was written by a newer build. /// @@ -74,3 +97,38 @@ pub enum CatalogError { #[error("io: {0}")] Io(String), } + +impl From for CatalogError { + fn from(e: rusqlite::Error) -> Self { + if is_corruption(&e) { + // `to_string` rather than keeping the error: the detail is going + // into a dialog and into a log line, and the recovery path has no + // use for the rusqlite type once it knows the file is damaged. + CatalogError::Corrupt { + detail: e.to_string(), + } + } else { + CatalogError::Sqlite(e) + } + } +} + +/// Whether a SQLite failure means the *file* is damaged rather than the +/// statement wrong. +/// +/// `SQLITE_NOTADB` is included because it is what a truncated or overwritten +/// catalog produces — SQLite cannot read the header, so it declines to call it +/// a database at all. To a user those are the same accident, and the same two +/// offers answer both. +/// +/// Deliberately *not* included: `SQLITE_CANTOPEN` (a missing file, which +/// `Connection::open` fixes by creating one), `SQLITE_BUSY`, and +/// `SQLITE_IOERR` — a failing disk or a dropped network mount is a different +/// problem, and telling the user to rebuild their index would be a wrong +/// answer delivered confidently. +fn is_corruption(e: &rusqlite::Error) -> bool { + matches!( + e.sqlite_error_code(), + Some(rusqlite::ErrorCode::DatabaseCorrupt) | Some(rusqlite::ErrorCode::NotADatabase) + ) +} diff --git a/core/dr-catalog/src/keywords.rs b/core/dr-catalog/src/keywords.rs index c3676ba..4326234 100644 --- a/core/dr-catalog/src/keywords.rs +++ b/core/dr-catalog/src/keywords.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5 +//! TRACES: FR-CAT-5 | FR-CAT-6 | NFR-R5 //! Keywords: the vocabulary, the assignments, and the edits the UI performs. //! //! The read half of this shipped with the catalog and the write half did not. diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 0ca953f..2cc9a86 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -21,6 +21,7 @@ //! - [`runner`] — the thing that drains it, driven by whoever owns the thread //! - [`trash`] — soft delete to a folder, then permanent delete //! - [`merge`] / [`sync`] — cross-device merging of collections and keywords +//! - [`recovery`] — backups, and the two offers made when this file is damaged //! //! # The one thing everything is designed around //! @@ -47,6 +48,7 @@ pub mod keywords; pub mod merge; pub mod query; pub mod rating; +pub mod recovery; pub mod runner; pub mod scan; pub mod schema; @@ -65,6 +67,7 @@ pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword}; pub use merge::MergeReport; pub use query::{Query, Sort}; pub use rating::{Judgement, MAX_RATING}; +pub use recovery::Backup; // Not `runner::Budget`: `cache::Budget` already owns that name here and // means something else entirely (bytes on disk, not jobs in a slot). // Callers spell the work budget `runner::Budget`, where it is unambiguous. @@ -222,9 +225,23 @@ pub struct Catalog { impl Catalog { /// Open or create a catalog, migrating it forward if needed. + /// + /// Does **not** verify the file — see [`Self::open_verified`], and + /// [`recovery`] for why the check is bound to startup rather than to every + /// open. Damage this trips over on the way past is still reported as + /// [`CatalogError::Corrupt`] rather than as a stray SQLite error. pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; schema::configure(&conn)?; + // NFR-R2, and the reason it is *here*: a migration is the one routine + // operation that rewrites table structure, so it is the likeliest way + // this file becomes unreadable — and afterwards there is no + // pre-migration state left to copy. A failure to take the copy is + // logged rather than raised: a full disk must not be the thing that + // makes a library unopenable. + if let Err(e) = recovery::backup_before_migration(&conn, path) { + log::warn!("could not back up before migrating: {e}"); + } let from = schema::migrate(&conn)?; // A migration adds a column; it cannot know what the value should be // for rows that already existed. Backfilling on open is what stops @@ -235,6 +252,26 @@ impl Catalog { Ok(Catalog { conn }) } + /// TRACES: NFR-R6 + /// Open a catalog, checking the file first. + /// + /// What startup calls. On [`CatalogError::Corrupt`] the caller has a user + /// in front of it and must make the two offers [`recovery`] describes, + /// rather than reporting a SQLite message on a banner and carrying on into + /// a scan that would write into the damage. + /// + /// Checked *before* opening rather than after, because opening runs + /// migrations: a damaged catalog that happens to have an intact header + /// would otherwise be migrated — rewriting structure on top of structure + /// that is already wrong — before anybody asked whether it was sound. + pub fn open_verified(path: &Path) -> Result { + // A catalog that is not there yet is not damaged; `open` creates it. + if path.is_file() { + recovery::check_file(path)?; + } + Self::open(path) + } + /// An in-memory catalog, for tests and for a throwaway import preview. pub fn in_memory() -> Result { let conn = Connection::open_in_memory()?; diff --git a/core/dr-catalog/src/recovery.rs b/core/dr-catalog/src/recovery.rs new file mode 100644 index 0000000..cb1b185 --- /dev/null +++ b/core/dr-catalog/src/recovery.rs @@ -0,0 +1,662 @@ +//! TRACES: NFR-R2 | NFR-R6 +//! What to do once the index is already damaged. +//! +//! # Why this can be a small module +//! +//! Because of a property the rest of the catalog was built to keep: the +//! catalog is an *index*, not a source of truth (ARCH §6.12, invariant +//! §5.2.4). Sidecars beside the images hold the authoritative ratings, +//! keywords and edit graphs, for every catalogued image and whether or not a +//! remote account exists (FR-CAT-8). So the worst outcome available here is a +//! rescan — expensive, but not a loss. +//! +//! That is the second offer. The first is cheaper and loses nothing at all: a +//! backup, restored. +//! +//! # The one thing a rebuild does not recover +//! +//! **Collections.** A manual collection is a set of images the user assembled +//! by hand and nothing in the filesystem records it (`docs/catalog.md` §8.1) — +//! which is the whole reason the catalog file itself syncs. So the two offers +//! are not interchangeable, and the interface must not present them as if they +//! were: a restore keeps the user's collections, a rebuild does not. +//! +//! # When the check runs, and when it does not +//! +//! [`integrity_check`] reads every page of the database. That is affordable +//! once, at startup, where a failure has a user in front of it who can answer +//! a question — and it is *not* affordable on every [`Catalog::open`], which +//! this application does per background task, dozens of times a session. So +//! the check is bound to [`Catalog::open_verified`] rather than to `open`, +//! and the cheap half of the story — classifying `SQLITE_CORRUPT` and +//! `SQLITE_NOTADB` as [`CatalogError::Corrupt`] — happens for free on every +//! query through [`crate::error`]'s conversion. A background job that trips +//! over the damage first therefore reports the same thing the startup check +//! would have. +//! +//! [`Catalog::open`]: crate::Catalog::open +//! [`Catalog::open_verified`]: crate::Catalog::open_verified + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::Connection; + +use crate::error::CatalogError; +use crate::schema; + +/// Directory backups live in, relative to the catalog file. +/// +/// Beside the catalog rather than in the cache directory, and that is the +/// point of the choice: this is the copy the user falls back on, and a cache +/// is a place the operating system is entitled to empty without asking +/// (see `library::data_root` for the same reasoning about sidecars). +const BACKUP_DIR: &str = "backups"; + +/// How many backups are kept. +/// +/// Small on purpose. A backup is a full copy of a catalog that is tens of +/// megabytes at 50k images, and the value of the third-oldest one is close to +/// zero: corruption is noticed at the next launch, not months later. What the +/// depth buys is protection against backing *up* the damage — if a corrupt +/// catalog is copied before anyone notices, the generation behind it is still +/// clean. +pub const KEEP_BACKUPS: usize = 3; + +/// Suffix given to a catalog that has been set aside as damaged. +/// +/// Kept rather than deleted. It costs disk this application would rather not +/// spend, and it is still the right call: `.sqlite` files have been recovered +/// by hand before, the user has not consented to a deletion, and NFR-R4's +/// instinct — never destroy what the user did not ask you to destroy — does +/// not stop applying at the catalog's edge. +const DAMAGED_SUFFIX: &str = "damaged"; + +/// One kept backup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Backup { + pub path: PathBuf, + /// UTC seconds at which it was taken, read from the filename rather than + /// from the filesystem: a copy, a restore or a sync can rewrite an mtime, + /// and then the newest backup is not the one that looks newest. + pub taken_at: i64, + pub bytes: u64, +} + +/// Where backups for `catalog` are kept. +pub fn backup_dir(catalog: &Path) -> PathBuf { + catalog + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(BACKUP_DIR) +} + +/// Check the database this connection is attached to. +/// +/// `quick_check` rather than `integrity_check`: the difference is that +/// `quick_check` skips verifying that every index agrees with its table, which +/// is the expensive half and the half this application least needs — every +/// index here is derivable, and `REINDEX` fixes one without anybody being +/// asked a question. What is left still reads every page, and catches the +/// damage that matters: torn b-trees, a bad freelist, a truncated file. +/// +/// Returns [`CatalogError::Corrupt`] carrying what SQLite said, so the message +/// the user sees is the diagnosis rather than a paraphrase of it. +pub fn integrity_check(conn: &Connection) -> Result<(), CatalogError> { + // The argument caps how many problems are reported. One is enough: the + // answer is the same whether the file has one damaged page or nine + // hundred, and an unbounded check on a badly damaged file can run for a + // very long time producing a list nobody will read. + let mut stmt = conn.prepare("PRAGMA quick_check(1)")?; + let rows: Vec = stmt + .query_map([], |r| r.get(0))? + .collect::, _>>()?; + + // A healthy database answers with the single row "ok". + if rows.len() == 1 && rows[0] == "ok" { + return Ok(()); + } + Err(CatalogError::Corrupt { + detail: rows.join("; "), + }) +} + +/// Check a catalog file that is not currently open. +/// +/// Used before a restore: a backup is only worth swapping in if it is sound, +/// and swapping in a second damaged file — leaving the user with no catalog +/// and no offer left — is the failure this exists to prevent. +pub fn check_file(path: &Path) -> Result<(), CatalogError> { + if !path.is_file() { + return Err(CatalogError::Io(format!("{} is missing", path.display()))); + } + // Read-write rather than read-only, which reads oddly for a check. A + // backup carries the WAL journal mode in its header because it was copied + // page-for-page from a WAL database, and SQLite cannot open one read-only + // without a shared-memory file it is then not allowed to create. Nothing + // here writes; the connection is opened, read, and dropped. + let conn = Connection::open(path)?; + integrity_check(&conn) +} + +/// Take a backup of the open catalog. +/// +/// Returns the file written. Older generations beyond [`KEEP_BACKUPS`] are +/// pruned, newest kept. +/// +/// Goes through `crate::sync::copy_to` — SQLite's own backup API after a +/// TRUNCATE checkpoint — rather than copying the file. A WAL database is not +/// one file, and `fs::copy` of the main file alone would silently back up a +/// state that is older than the catalog and possibly torn, which is the one +/// failure mode a backup cannot afford. +pub fn backup(conn: &Connection, catalog: &Path) -> Result { + let dir = backup_dir(catalog); + std::fs::create_dir_all(&dir) + .map_err(|e| CatalogError::Io(format!("creating {}: {e}", dir.display())))?; + + let dest = dir.join(format!("catalog-{}.sqlite", now())); + // A second backup within the same second would otherwise land on the first + // one's name. Rare, and only reachable from tests and a retry, but the + // result would be a half-overwritten backup rather than two. + if dest.exists() { + std::fs::remove_file(&dest) + .map_err(|e| CatalogError::Io(format!("replacing {}: {e}", dest.display())))?; + } + + // Dropped immediately: the copy is complete when `copy_to` returns, and + // holding the connection open would leave a `-wal` beside a file whose + // whole purpose is to be a single self-contained artefact. + drop(crate::sync::copy_to(conn, &dest)?); + + prune(catalog); + Ok(dest) +} + +/// Back up before a migration, if there is anything to back up. +/// +/// Called from [`Catalog::open`](crate::Catalog::open) between `configure` and +/// `migrate`. NFR-R2 asks for this and the reasoning is narrower than "backups +/// are prudent": a migration is the one routine operation that rewrites table +/// structure, so it is the likeliest way a catalog becomes unreadable, and it +/// is the one moment where the pre-change state is still on disk to be copied. +/// Afterwards there is nothing left to take a copy *of*. +/// +/// A no-op in the two cases where it would cost without buying anything: a +/// catalog already at [`schema::SCHEMA_VERSION`], and a brand-new file at +/// version 0 with no tables in it yet. +pub fn backup_before_migration(conn: &Connection, catalog: &Path) -> Result<(), CatalogError> { + let from: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?; + if from == 0 || from >= schema::SCHEMA_VERSION { + return Ok(()); + } + let path = backup(conn, catalog)?; + log::info!( + "backed up catalog at v{from} to {} before migrating to v{}", + path.display(), + schema::SCHEMA_VERSION + ); + Ok(()) +} + +/// The backups available for `catalog`, newest first. +/// +/// Never fails: an unreadable or absent backup directory means there are no +/// backups, which is a fact about the offer to make rather than an error to +/// report on top of the corruption the user is already looking at. +pub fn backups(catalog: &Path) -> Vec { + let dir = backup_dir(catalog); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + + let mut out: Vec = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + let taken_at = timestamp_of(&path)?; + let bytes = e.metadata().ok()?.len(); + Some(Backup { + path, + taken_at, + bytes, + }) + }) + .collect(); + out.sort_by_key(|b| std::cmp::Reverse(b.taken_at)); + out +} + +/// Put a backup back in place of the damaged catalog. +/// +/// **Every connection to `catalog` must be closed first.** This replaces the +/// file underneath anything still holding it open, which on a live connection +/// is how a *second* corrupt catalog gets made. +/// +/// The order is deliberate: +/// +/// 1. The backup is checked. A restore that installs a second damaged file +/// leaves the user with nothing to try next. +/// 2. The damaged catalog is renamed aside, and its `-wal` and `-shm` are +/// **deleted**. This is the step that is easy to leave out and fatal to +/// leave out: a journal belonging to the old file, sitting beside the new +/// one under the same name, is replayed into it on the next open. That is +/// not a restore, it is a fresh corruption with the evidence gone. +/// 3. The backup is *copied* into place, not moved, so a failure here can be +/// retried against the same backup. +pub fn restore(catalog: &Path, backup: &Path) -> Result<(), CatalogError> { + check_file(backup)?; + set_aside(catalog)?; + std::fs::copy(backup, catalog).map_err(|e| { + CatalogError::Io(format!( + "restoring {} from {}: {e}", + catalog.display(), + backup.display() + )) + })?; + log::info!( + "restored {} from backup {}", + catalog.display(), + backup.display() + ); + Ok(()) +} + +/// Move a damaged catalog out of the way so the next open builds a fresh one. +/// +/// This is the rebuild path (NFR-R6's second offer) and also the first half of +/// a [`restore`]. Nothing else is needed to rebuild: the next +/// [`Catalog::open`](crate::Catalog::open) creates an empty catalog at the +/// current schema, and the ordinary scan repopulates it from sources and +/// sidecars — which is precisely invariant §5.2.4 being spent rather than +/// merely asserted. +/// +/// Returns where the damaged file was put, or `None` if there was no catalog +/// to move — a caller may be recovering from a file SQLite could not open +/// because it was never created. +pub fn set_aside(catalog: &Path) -> Result, CatalogError> { + let moved = if catalog.exists() { + let dest = with_suffix(catalog, DAMAGED_SUFFIX); + // An earlier damaged copy is replaced rather than accumulating: two of + // these is two full-size catalogs on the user's disk, and the older + // one has already been superseded by a recovery the user completed. + let _ = std::fs::remove_file(&dest); + // The rename first, so that a failure here leaves the journals with + // the file they belong to rather than orphaned beside a catalog that + // is still in use. + std::fs::rename(catalog, &dest).map_err(|e| { + CatalogError::Io(format!( + "setting aside {} as {}: {e}", + catalog.display(), + dest.display() + )) + })?; + log::warn!( + "catalog {} was damaged; kept as {}", + catalog.display(), + dest.display() + ); + Some(dest) + } else { + None + }; + + // Then the journals, whether or not there was a catalog to move: a `-wal` + // orphaned beside a missing database is replayed into whatever takes that + // name next, which would not be a restore but a fresh corruption with the + // evidence gone. + for sidecar in journals(catalog) { + if let Err(e) = std::fs::remove_file(&sidecar) { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(CatalogError::Io(format!( + "removing stale journal {}: {e}", + sidecar.display() + ))); + } + } + } + + Ok(moved) +} + +/// Delete backups beyond [`KEEP_BACKUPS`]. +/// +/// Best-effort and silent about individual failures: failing to delete an old +/// backup is not a reason to fail the new one, which is already written. +fn prune(catalog: &Path) { + for old in backups(catalog).into_iter().skip(KEEP_BACKUPS) { + if let Err(e) = std::fs::remove_file(&old.path) { + log::warn!("could not prune backup {}: {e}", old.path.display()); + } + } +} + +/// The WAL and shared-memory files SQLite keeps beside a database. +fn journals(catalog: &Path) -> [PathBuf; 2] { + [with_suffix(catalog, "wal"), with_suffix(catalog, "shm")] +} + +/// `catalog.sqlite` plus `-suffix`, the way SQLite names its own sidecars. +/// +/// Appended to the whole filename rather than replacing the extension, so +/// `catalog.sqlite-wal` is what SQLite would look for and `catalog.sqlite- +/// damaged` sorts next to the catalog it came from. +fn with_suffix(catalog: &Path, suffix: &str) -> PathBuf { + let mut s = catalog.as_os_str().to_os_string(); + s.push("-"); + s.push(suffix); + PathBuf::from(s) +} + +/// Read the timestamp out of a backup's filename, or `None` if this is not one. +/// +/// Doubles as the filter that keeps [`backups`] from offering the user +/// something that is not a catalog — a stray file in the directory, or a `-wal` +/// left by a crash mid-backup. +fn timestamp_of(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + name.strip_prefix("catalog-")? + .strip_suffix(".sqlite")? + .parse() + .ok() +} + +/// Seconds since the epoch, or 0 if the clock is before it. +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + use std::io::{Seek, SeekFrom, Write}; + + /// A scratch directory that cleans up with the test. + fn tempdir(tag: &str) -> PathBuf { + let base = std::env::temp_dir().join(format!( + "dr-recovery-{tag}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base + } + + /// A catalog on disk with enough rows to span several pages, closed. + /// + /// Closed matters: WAL means the rows are in `catalog.sqlite-wal` until + /// something checkpoints, and a test that corrupted the main file while + /// the data was still in the journal would be corrupting empty space. + fn fixture(path: &Path, images: i64) { + let cat = Catalog::open(path).unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO collections(uuid, name, kind, created, revision, modified) + VALUES ('u1', 'Iceland', 0, 0, 1, 1)", + [], + ) + .unwrap(); + for i in 1..=images { + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (?1, 1, ?2, 0)", + rusqlite::params![i, format!("DCIM/IMG_{i:05}.CR3")], + ) + .unwrap(); + } + crate::sync::checkpoint(c).unwrap(); + drop(cat); + } + + /// Scribble over everything past the first two pages. + /// + /// Past them rather than over them so that page 1 — the header and the + /// schema — survives: this produces a file SQLite is willing to open and + /// then finds damaged, which is the case `quick_check` exists for. Wiping + /// the header instead produces `SQLITE_NOTADB` at the first pragma, which + /// is a different branch and has its own test. + fn corrupt(path: &Path) { + let mut f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + let len = f.metadata().unwrap().len(); + assert!( + len > 8192, + "fixture is only {len} bytes; corrupting past page 2 would be a no-op" + ); + let junk = vec![0x5a_u8; (len - 8192) as usize]; + f.seek(SeekFrom::Start(8192)).unwrap(); + f.write_all(&junk).unwrap(); + f.sync_all().unwrap(); + } + + #[test] + fn a_healthy_catalog_passes() { + let cat = Catalog::in_memory().unwrap(); + integrity_check(cat.connection()).unwrap(); + } + + #[test] + fn a_corrupt_catalog_is_reported_as_corrupt_not_as_sqlite() { + // The whole point of the variant: this used to arrive as whatever + // rusqlite error the first failing query produced, with nowhere to + // hang a recovery offer. + let dir = tempdir("detect"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 500); + corrupt(&path); + + assert!(matches!( + Catalog::open_verified(&path), + Err(CatalogError::Corrupt { .. }) + )); + } + + #[test] + fn a_file_that_is_not_a_database_is_also_corrupt() { + // A truncated or overwritten catalog never reaches `quick_check`: the + // first pragma fails with SQLITE_NOTADB. Same accident to the user, + // same two offers, so it must classify the same way. + let dir = tempdir("notadb"); + let path = dir.join("catalog.sqlite"); + std::fs::write(&path, b"this is not a catalog, it is a text file\n").unwrap(); + + assert!(matches!( + Catalog::open(&path), + Err(CatalogError::Corrupt { .. }) + )); + } + + #[test] + fn restoring_a_backup_recovers_the_collections_a_rebuild_would_lose() { + // The first NFR-R6 branch, asserted on the thing that distinguishes it + // from the second: a collection exists nowhere but the catalog, so it + // is the evidence that the *contents* came back and not merely a + // readable file (docs/catalog.md §8.1). + let dir = tempdir("restore"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 500); + + { + let cat = Catalog::open(&path).unwrap(); + backup(cat.connection(), &path).unwrap(); + } + corrupt(&path); + assert!(matches!( + Catalog::open_verified(&path), + Err(CatalogError::Corrupt { .. }) + )); + + let newest = backups(&path).into_iter().next().expect("a backup exists"); + restore(&path, &newest.path).unwrap(); + + let cat = Catalog::open_verified(&path).unwrap(); + let name: String = cat + .connection() + .query_row("SELECT name FROM collections", [], |r| r.get(0)) + .unwrap(); + assert_eq!(name, "Iceland"); + let images: i64 = cat + .connection() + .query_row("SELECT count(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(images, 500); + } + + #[test] + fn a_damaged_backup_is_refused_rather_than_installed() { + let dir = tempdir("badbackup"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 500); + { + let cat = Catalog::open(&path).unwrap(); + backup(cat.connection(), &path).unwrap(); + } + let newest = backups(&path).into_iter().next().unwrap(); + corrupt(&newest.path); + corrupt(&path); + + assert!(matches!( + restore(&path, &newest.path), + Err(CatalogError::Corrupt { .. }) + )); + // And the damaged catalog is still where it was, so the second offer + // is still available. + assert!(path.exists()); + } + + #[test] + fn setting_aside_leaves_a_fresh_catalog_to_rebuild_into() { + // The second NFR-R6 branch. What makes it a rebuild rather than a data + // loss is invariant §5.2.4, which lives outside this crate — what is + // testable here is that the damaged file is out of the way, kept, and + // that the next open succeeds on an empty catalog at the current + // schema, which is what a scan then fills. + let dir = tempdir("rebuild"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 500); + corrupt(&path); + + let kept = set_aside(&path).unwrap().expect("the catalog was there"); + assert!(kept.exists(), "the damaged catalog was deleted, not kept"); + assert!(!path.exists()); + + let cat = Catalog::open_verified(&path).unwrap(); + let images: i64 = cat + .connection() + .query_row("SELECT count(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(images, 0); + let v: i64 = cat + .connection() + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, schema::SCHEMA_VERSION); + } + + #[test] + fn a_stale_journal_does_not_follow_the_catalog_into_recovery() { + // The step that is easy to omit: a `-wal` belonging to the damaged + // file is replayed into whatever takes its name next. + let dir = tempdir("journal"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 500); + std::fs::write(with_suffix(&path, "wal"), b"stale").unwrap(); + + set_aside(&path).unwrap(); + assert!(!with_suffix(&path, "wal").exists()); + } + + #[test] + fn a_migration_is_backed_up_before_it_runs() { + // NFR-R2's second clause, against a real v1 catalog rather than a + // faked version number: the point is not that *a* file appears but + // that it holds the state from before the migration, which is the only + // state that is any use if the migration is what breaks it. + let dir = tempdir("premigrate"); + let path = dir.join("catalog.sqlite"); + { + let c = Connection::open(&path).unwrap(); + schema::configure(&c).unwrap(); + // `v1_for_attached` names the schema it targets, and "main" is a + // schema like any other — so this is the real v1, without needing + // `V1` itself to become visible outside its module. + c.execute_batch(&schema::v1_for_attached("main")).unwrap(); + c.pragma_update(None, "user_version", 1).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + crate::sync::checkpoint(&c).unwrap(); + } + assert!(backups(&path).is_empty()); + + Catalog::open(&path).unwrap(); + + let taken = backups(&path); + assert_eq!(taken.len(), 1, "no backup was taken before the migration"); + check_file(&taken[0].path).unwrap(); + let kept = Connection::open(&taken[0].path).unwrap(); + let v: i64 = kept + .query_row("PRAGMA user_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 1, "the backup was taken after the migration, not before"); + } + + #[test] + fn opening_an_up_to_date_catalog_takes_no_backup() { + // Or every background task that opens the catalog would copy it. + let dir = tempdir("nobackup"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 10); + Catalog::open(&path).unwrap(); + assert!(backups(&path).is_empty()); + } + + #[test] + fn only_the_newest_generations_are_kept() { + let dir = tempdir("prune"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 10); + let cat = Catalog::open(&path).unwrap(); + + // Written by hand rather than by calling `backup` in a loop: the + // filename carries whole seconds, so real calls would collide. + std::fs::create_dir_all(backup_dir(&path)).unwrap(); + for t in 1..=KEEP_BACKUPS as i64 + 2 { + drop( + crate::sync::copy_to( + cat.connection(), + &backup_dir(&path).join(format!("catalog-{t}.sqlite")), + ) + .unwrap(), + ); + } + prune(&path); + + let kept = backups(&path); + assert_eq!(kept.len(), KEEP_BACKUPS); + // Newest first, and the newest is the highest timestamp. + assert_eq!(kept[0].taken_at, KEEP_BACKUPS as i64 + 2); + } + + #[test] + fn a_stray_file_in_the_backup_directory_is_not_offered_as_one() { + let dir = tempdir("stray"); + let path = dir.join("catalog.sqlite"); + fixture(&path, 10); + std::fs::create_dir_all(backup_dir(&path)).unwrap(); + std::fs::write(backup_dir(&path).join("notes.txt"), b"hello").unwrap(); + std::fs::write(backup_dir(&path).join("catalog-7.sqlite-wal"), b"x").unwrap(); + + assert!(backups(&path).is_empty()); + } +} diff --git a/core/dr-catalog/src/sync.rs b/core/dr-catalog/src/sync.rs index 975d82a..67022b6 100644 --- a/core/dr-catalog/src/sync.rs +++ b/core/dr-catalog/src/sync.rs @@ -52,6 +52,21 @@ pub fn checkpoint(conn: &Connection) -> Result<(), CatalogError> { /// coherent even with writers active. Callers should still prefer a quiet /// moment — this competes with background jobs for the write lock. pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), CatalogError> { + let out = copy_to(conn, dest)?; + strip_face_crops(&out)?; + Ok(()) +} + +/// Checkpoint, then copy the whole database to `dest`, and hand back the +/// connection to the copy. +/// +/// Split out from [`snapshot_for_upload`] because [`crate::recovery`] wants +/// exactly this and none of what follows it there: an NFR-R2 backup is the +/// file the user may have to *live on*, so it keeps the face crops that an +/// upload strips. Sharing the copy rather than reimplementing it is what keeps +/// the WAL discipline in one place — a backup taken with `fs::copy` would be +/// the torn snapshot this module's header exists to warn about. +pub(crate) fn copy_to(conn: &Connection, dest: &Path) -> Result { checkpoint(conn)?; let mut out = Connection::open(dest)?; @@ -63,8 +78,7 @@ pub fn snapshot_for_upload(conn: &Connection, dest: &Path) -> Result<(), Catalog backup.run_to_completion(i32::MAX, std::time::Duration::ZERO, None)?; drop(backup); - strip_face_crops(&out)?; - Ok(()) + Ok(out) } /// Drop the stored face crops from a snapshot before it is uploaded. diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index f70d91c..6f83616 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1727,6 +1727,24 @@ mod tests { assert!(!restored.is_neutral()); } + /// TRACES: FR-PLG-8 + /// FR-PLG-8's preservation clause, and only that clause. + /// + /// The requirement opens by saying the sidecar "already preserves lines it + /// does not understand verbatim and writes them back untouched", and that + /// the property "is now load-bearing and shall be treated as such". This is + /// the test that makes it load-bearing rather than incidental. + /// + /// **It is weaker than the requirement's stated acceptance criterion**, + /// which is that the re-saved file be *byte-identical* to the original. + /// This asserts `contains`. The two halves of that criterion exist + /// separately — `writing_the_same_state_twice_is_byte_identical` proves + /// byte identity for content this build understands, and this proves + /// survival for content it does not — and nothing yet joins them into the + /// one claim FR-PLG-8 makes. Everything else the requirement asks for + /// (aggregated alerts, the export gate, rendering without rather than + /// guessing) is unbuilt, which is why the tag is on these two tests and + /// not on the module. #[test] fn an_unknown_operation_survives_a_round_trip() { // The data-loss case that matters: a device running an older build @@ -1742,6 +1760,13 @@ mod tests { ); } + /// TRACES: FR-PLG-8 + /// The other half of preservation: kept in the file, kept out of the edit. + /// + /// "Render without, never render a guess" is a separate clause, and this is + /// not it — that one is about a *recognised* operation whose plugin is + /// missing. This is the narrower claim that an unparsed line cannot reach + /// the graph by accident, which is what makes preserving it safe. #[test] fn an_unknown_operation_does_not_reach_the_graph() { let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 987c111..505f930 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -51,7 +51,7 @@ pub struct CollectionId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FolderId(pub u64); -/// TRACES: FR-CAT-1a | FR-PLAT-AND-1 +/// TRACES: FR-CAT-1a /// An opaque, re-resolvable reference to source image data. /// /// **Never a filesystem path.** Android's Storage Access Framework provides no diff --git a/docs/bench-baseline.json b/docs/bench-baseline.json new file mode 100644 index 0000000..579873f --- /dev/null +++ b/docs/bench-baseline.json @@ -0,0 +1,113 @@ +{ + "_readme": [ + "The committed numbers for DarkRoom's benchmark suite (docs/requirements.md §8).", + "Produced and checked by `cargo run --release -p dr-bench`; docs/benchmarks.md explains each metric.", + "", + "Two gates, and they are not the same gate. `budget` is the requirement's own threshold and never moves.", + "`recorded` is what the reference desktop last measured, and a run that drifts past `tolerance` beyond it", + "fails the build even while still inside the budget — which is how most performance rot actually arrives.", + "", + "`recorded` is null on every metric because nobody has run the suite yet. That is deliberate: writing", + "plausible-looking figures here would make every later comparison a comparison against a guess. Run", + "`cargo run --release -p dr-bench -- record --reference` on the reference desktop and commit the diff.", + "Until then the budget gate works and the regression gate says, in the report, that it cannot.", + "", + "`machine_sensitive` says whether a budget is a statement about a machine as much as about the code.", + "Those budgets are asserted only under --reference: §8 names the reference desktop, and a two-core CI", + "container cannot speak to a target written for twenty-four threads. Asserting one there would produce a", + "red gate everybody learns to ignore, which is the trap core/dr-gpu/tests/frame_budget.rs already avoids.", + "", + "Several metrics carry a requirement ID with a qualifier. Read those literally. NFR-P7's budget here is", + "checked against the encode half of an export only — no GPU render is in the figure — so it can fail the", + "requirement and cannot pass it, and no TRACES tag claims otherwise. NFR-P8 has no budget at all yet,", + "because nobody has decided how much of its 500 MB belongs to the catalog layer; this records the number", + "that decision needs." + ], + "tolerance": 0.15, + "recorded_on": null, + "recorded_at_unix": null, + "fixture": null, + "metrics": { + "catalog_filtered_ms": { + "requirement": "FR-CAT-6", + "what": "Count plus first window under a rating filter, which compiles to a correlated subquery over versions.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": true, + "budget": null, + "recorded": null + }, + "catalog_idle_rss_mb": { + "requirement": "NFR-P8 (the catalog layer's share only — no toolkit, no adapter, no decode cache)", + "what": "Resident memory of a process that opened the 50k catalog and scrolled ten thousand rows.", + "unit": "MB", + "direction": "lower_is_better", + "machine_sensitive": false, + "budget": null, + "recorded": null + }, + "catalog_open_ms": { + "requirement": "NFR-P1", + "what": "Catalog::open plus the count, first window and timeline the grid cannot paint without.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": false, + "budget": 2000.0, + "recorded": null + }, + "catalog_open_warm_ms": { + "requirement": "NFR-P1", + "what": "The same four calls on a second connection, with SQLite's page cache already warm.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": false, + "budget": 2000.0, + "recorded": null + }, + "catalog_window_p99_ms": { + "requirement": "FR-CAT-4", + "what": "One 400-row grid window at a random offset, p99 of one hundred.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": true, + "budget": null, + "recorded": null + }, + "export_24mp_long_edge_2048_ms": { + "requirement": "FR-EXP-3", + "what": "The web export: resample a 24 MP frame to a 2048 px long edge, sharpen, encode. p99 of five.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": true, + "budget": null, + "recorded": null + }, + "export_24mp_original_ms": { + "requirement": "NFR-P7 (the encode half only — the GPU render is not in this figure)", + "what": "Resample, output-sharpen and JPEG-encode a 24 MP frame at source size. p99 of five.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": true, + "budget": 2000.0, + "recorded": null + }, + "thumbnail_per_image_p99_ms": { + "requirement": "NFR-P3", + "what": "One thumbnail on its own lane: decode the preview, downscale, orient, encode. p99.", + "unit": "ms", + "direction": "lower_is_better", + "machine_sensitive": true, + "budget": null, + "recorded": null + }, + "thumbnail_throughput_ips": { + "requirement": "NFR-P3", + "what": "Whole-sweep throughput: 1200 thumbnails through the sweep's chunk-and-lane shape, wall clock.", + "unit": "img/s", + "direction": "higher_is_better", + "machine_sensitive": true, + "budget": 100.0, + "recorded": null + } + } +} diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..5947862 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,243 @@ +# The benchmark suite + +**Status:** Built, not yet recorded · 2026-08-30 +**Companion to:** [requirements.md](requirements.md) §4.1 (performance targets) · §8 (verification) +**Instrument:** [`tools/bench`](../tools/bench) — `cargo run --release -p dr-bench -- check` +**Committed numbers:** [`bench-baseline.json`](bench-baseline.json) +**GPU half:** [`core/dr-gpu/tests/frame_budget.rs`](../core/dr-gpu/tests/frame_budget.rs) · +[frame-budget.md](frame-budget.md) + +§8 has said since it was written that performance is verified by *"an automated +benchmark suite against a synthetic 50k catalog, run per-commit … A regression +beyond stated tolerance fails the build."* Until this suite there was none. No +`benches/`, no `[[bench]]`, no criterion, no fixture — and ten performance +requirements that could therefore be neither passed nor failed, five of them +carrying a `TRACES:` tag regardless. + +This file is what the suite covers, what it deliberately does not, and how to +read a failure. + +--- + +## The state of it, first + +**No numbers have been recorded yet.** Every `recorded` field in +[`bench-baseline.json`](bench-baseline.json) is `null`, on purpose: writing +plausible-looking figures into a baseline would make every later comparison a +comparison against a guess, and the first real regression would be invisible. + +To record them, on the reference desktop: + +```sh +cargo run --release -p dr-bench -- record --reference +``` + +and commit the diff. Until that happens the **budget** gate works — a catalog +that takes three seconds to open fails the build today — and the **regression** +gate reports that it has nothing to compare against, rather than pretending. + +--- + +## What it measures + +| Metric | Requirement | Gated? | +|---|---|---| +| `catalog_open_ms` | **NFR-P1**, and R2's second sentence | Yes, everywhere — budget 2000 ms | +| `catalog_open_warm_ms` | NFR-P1, page cache warm | Yes, everywhere — budget 2000 ms | +| `catalog_window_p99_ms` | FR-CAT-4 | Regression only | +| `catalog_filtered_ms` | FR-CAT-6 | Regression only | +| `thumbnail_throughput_ips` | **NFR-P3** | Budget 100 img/s, on the reference desktop | +| `thumbnail_per_image_p99_ms` | NFR-P3 | Regression only | +| `export_24mp_original_ms` | NFR-P7, **encode half only** | One-sided: can fail it, cannot pass it | +| `export_24mp_long_edge_2048_ms` | FR-EXP-3 | Regression only | +| `catalog_idle_rss_mb` | NFR-P8, **catalog layer only** | Regression only — see below | + +Two of those rows carry a qualifier, and the qualifiers are the point. + +### Requirements this can now pass *or* fail + +**NFR-P1 — catalog open under 2 s.** The measured span is the four things the +library view cannot paint without: `Catalog::open` (which connects, migrates and +**backfills**, and the backfill is three passes over the images table on every +open), `count`, the first 400-row `window`, and the monthly `timeline`. Tagged +`TRACES: NFR-P1` in [`tools/bench/src/catalog_open.rs`](../tools/bench/src/catalog_open.rs), +because a build that breaks it fails this gate. + +**NFR-P3 — ≥ 100 images per second on the embedded preview path.** The +per-image work is exactly what `spawn_thumbnail_sweep` does — `decode_jpeg`, +`Preview::downscale_to`, `Preview::apply_orientation`, `encode_rgba`, +`ThumbStore::put` — arranged in the same shape: chunks of 96, lanes owning +disjoint slices, and the single thread that owns the store writing the finished +chunk. Tagged `TRACES: NFR-P3` in +[`tools/bench/src/thumbnails.rs`](../tools/bench/src/thumbnails.rs). + +### Requirements this can only half-answer, and is not tagged for + +**NFR-P7 — 24 MP export under 2 s, full chain.** The full chain is decode, +demosaic, a full-resolution GPU render, a read-back, then resize, sharpen and +encode. Only the last three run without an adapter. So the figure here is a +**lower bound** on the requirement: exceeding 2 s in the encode alone violates +NFR-P7 no matter how fast the render is, and coming in under it proves nothing. +The budget is gated on that basis and there is no `TRACES: NFR-P7` anywhere in +`tools/bench`. + +**NFR-P8 — idle memory under 500 MB.** The probe is a fresh process holding the +catalog and nothing else: no Slint, no wgpu device, no font stack, no decode +cache. Its RSS is the catalog layer's *share* of that 500 MB, not the figure the +requirement is about. It carries no budget for a reason given below. + +### Requirements out of scope, listed so their absence reads as a decision + +NFR-P2 (grid scroll at 60 fps), P4 (open in develop), P5 (slider to visible), +P6 (pan/zoom), P9 (UI-executor blocking), P10 (touch response), P11 (layout +transition), P12 (warm shader setup), P13 (next image in culling), P14 (focus +peaking), P15 (drawn mask stroke). Every one of them needs a frame-timing probe +inside a running Slint application, a GPU adapter, or both. None is faked here. + +The GPU half of the story that *does* exist is +[frame-budget.md](frame-budget.md) and its guard test, which asserts FR-DSP-3 +and skips itself where there is no adapter. `.gitea/workflows/benchmark.yml` +runs it as its own job for exactly that reason. + +--- + +## The fixture + +Fifty thousand rows over a pool of twelve real image files. Rows are cheap and +pixels are not: everything the catalog half touches is rows and is therefore +exact at full scale, and everything the pixel half touches is one file at a time +and does not care how many rows point at it. The result is ~14 MB on disk +instead of ~2 TB, and neither half is flattered by that. + +| | | +|---|---| +| Rows | 50,000 images, 50,000 default versions, 400 folders, one root | +| Capture times | Twelve years from a fixed epoch, so the timeline has ~144 monthly buckets | +| Sources | 12 synthesised JPEGs at 1620 × 1080 — the size `dr-decode` records a CR2 carrying in IFD2 | +| Seed | 20260829, in [`tools/bench/src/main.rs`](../tools/bench/src/main.rs) | +| Location | `$DR_BENCH_DIR`, else the system temporary directory | + +It is reproducible from the seed, and a `stamp.json` beside it records what it +was built from — seed, row count, source count, preview size, and `dr-catalog`'s +schema version. A mismatch rebuilds rather than silently measuring a different +workload than the baseline describes. + +Two honest limits on it: + +- **The page cache is warm.** The fixture was written by this suite or by an + earlier run of it, so neither the catalog open nor the thumbnail sweep pays + for a cold disk. On the reference desktop's NVMe a genuinely cold read of a + 14 MB catalog is tens of milliseconds; on spinning rust it is not. +- **The sources are synthetic.** A coarse gradient with a fine dither, which is + what `frame_budget.rs` synthesises for the same reason — a flat frame lets the + memory system serve every sample from one cache line and flatters a box + filter, and pure noise defeats the entropy coder in the other direction. + +--- + +## Two gates, and how to read a failure + +**Budget.** The requirement's own threshold. It does not move. Failing it means +a requirement is violated. + +**Regression.** More than 15% worse than the last recorded figure *on the same +machine, against the same fixture*. Failing it means the code got slower while +still inside the requirement — which is how most performance rot actually +arrives, never over the line, always a little worse, until one day the line is +crossed by a change that was not the cause. + +A metric declares whether its budget is `machine_sensitive`. Those are asserted +only under `--reference`, and reported everywhere else. §8 names *"the reference +desktop"*, not CI, and it is right to: a container with two cores cannot speak +to a throughput target written for twenty-four threads, and asserting one there +would produce exactly what `core/dr-gpu/tests/frame_budget.rs` refused to +produce — *"a red suite that everyone learns to ignore"*. Catalog open is not +machine-sensitive: 2 s against an expected figure two orders of magnitude +smaller is a threshold any machine can be held to. + +Exit codes: `0` everything passed, `1` a gate failed, `2` the harness itself +could not run. Distinguished so a CI log that says "failed" does not leave +anyone guessing whether the code got slower or the fixture would not build. + +**Release, always.** The workspace builds its own crates at `opt-level = 0` in +dev, and every figure here is dominated by this workspace's own code — the JPEG +decode, the box filter, the resample, the sharpen. A debug run measures rustc's +shadow. The report says which profile it was built in on its second line. + +--- + +## NFR-P8, and the question §4.1 asks + +§4.1 says NFR-P8 *"must state whether it measures RSS inclusive or exclusive of +GPU allocations, and whether it holds after SQLite's page cache warms on a 50k +catalog."* Both halves have an answer. + +**On the page cache: warm.** The probe runs the count, the timeline and +twenty-five windows before it reads its counters, so SQLite's cache holds the +b-tree pages a scroll touches. That is the right side to err on — a figure taken +before the cache warms would understate a steady-state library. + +**On GPU memory: RSS is exclusive of device-local allocations, and cannot be +made otherwise.** A Vulkan allocation in a device-local heap never enters the +process's address space, so nothing under `/proc/self/status` can see it. What +*does* land in RSS is the host-visible side — staging buffers, mapped upload +rings, the read-back `AdjustPass` performs on export — plus the driver's own +resident pages. + +So "idle memory < 500 MB" is two questions wearing one number, and a build +holding 400 MB of RSS and 3 GB of textures would pass it. + +**Recommendation: NFR-P8 should be restated as two figures** — host RSS +exclusive of device-local memory, and a separate VRAM ceiling read from the +adapter — because the second is the one that decides whether the application +survives beside a browser on an 8 GB card, and nothing in this repository +measures it today. + +**And a decision is outstanding.** `catalog_idle_rss_mb` carries no budget +because nobody has decided how much of the 500 MB belongs to the catalog layer +and how much to everything above it. The suite records the number so that +decision can be taken against a measurement rather than an estimate. When it is +taken, put the figure in `budget` and the metric becomes a gate. + +--- + +## What is not measured, and would be worth adding + +- **The UI's own open.** `ui/dr-ui/src/library.rs` does not call + `Catalog::count` or `Catalog::window`; it issues its own SQL against the same + tables, with a `VISIBLE` predicate and a burst-folding clause. `dr-bench` + cannot see those without depending on `dr-ui`, which would drag Slint into a + job that has no display. **Falsifiable end:** when the grid's queries move + down into `dr-catalog` — which is where SQL over catalog tables belongs — + `catalog_open_ms` becomes the whole of the application's open and this caveat + can be deleted rather than argued about. +- **The remote sweep.** `spawn_thumbnail_sweep`'s wall clock against a real + server is latency, not CPU, and is what FR-NC-3's design is judged by. It + needs a server and belongs in a different kind of test. +- **A cold disk.** See the fixture's limits above. +- **Android.** §4.1 states a second column of targets and §8 asks for + "periodically on the named reference Android devices". Nothing here runs on a + device. Spike S10 is the piece of work that would start it. +- **Everything with a frame in it.** See the out-of-scope list above. + +--- + +## Running it + +```sh +# Measure and print. Judges nothing. +cargo run --release -p dr-bench -- run + +# Measure and gate. What CI runs. +cargo run --release -p dr-bench -- check + +# The same, with machine-sensitive budgets asserted too. +cargo run --release -p dr-bench -- check --reference + +# Rewrite bench-baseline.json from this run, and commit the diff. +cargo run --release -p dr-bench -- record --reference +``` + +Useful flags: `--fixture ` (or `$DR_BENCH_DIR`) to put the synthetic +catalog somewhere specific, `--lanes ` to pin the sweep's parallelism, and +`--thumbnails ` to lengthen or shorten the throughput row. diff --git a/docs/gestures.md b/docs/gestures.md index 39a2695..92df466 100644 --- a/docs/gestures.md +++ b/docs/gestures.md @@ -5,7 +5,7 @@ Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it. -15 gestures, in 2 places. +16 gestures, in 2 places. ## People @@ -23,7 +23,7 @@ Grouping over-merges on siblings, on parents and children, and on the same perso - **Touch** — Tick to confirm it, cross to reject it - **Pointer** — Tick to confirm it, cross to reject it -A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. +A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on. `ui/dr-ui/ui/identity.slint:150` @@ -34,7 +34,7 @@ A face is either the system's guess or the user's judgement, and the two are nev This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles. -`ui/dr-ui/ui/identity.slint:545` +`ui/dr-ui/ui/identity.slint:552` ### Change how faces are grouped @@ -43,7 +43,7 @@ This is the point of having identified anybody. Without it the screen is a filin The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say. -`ui/dr-ui/ui/identity.slint:582` +`ui/dr-ui/ui/identity.slint:589` ## Library grid @@ -54,7 +54,7 @@ The right match confidence is a property of your library, not of the model. "Wha Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found. -`ui/dr-ui/ui/library.slint:1182` +`ui/dr-ui/ui/library.slint:1184` ### Add or remove one photograph @@ -63,7 +63,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back. -`ui/dr-ui/ui/library.slint:1191` +`ui/dr-ui/ui/library.slint:1193` ### Leave selecting @@ -71,7 +71,16 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea - **Pointer** — Press Done in the header - **Keyboard** — Escape -`ui/dr-ui/ui/library.slint:1199` +`ui/dr-ui/ui/library.slint:1201` + +### Pick a photograph up to drag it + +- **Touch** — Press and hold it until a ring opens around it, then drag +- **Pointer** — Drag it + +A finger on a photograph might be starting a scroll, and for the first half-second the grid assumes it is. Holding says otherwise, and the ring is the grid saying it heard — from there the drag cannot be lost to a scroll. A mouse never waits: the cursor is precise enough that a sideways drag is unambiguous from the first pixel. + +`ui/dr-ui/ui/library.slint:1231` ### Select a range @@ -80,7 +89,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea This replaced a double tap, which had no visible state and could take forty photographs by accident. The run is resolved by the catalog rather than by what is on screen, so the grid can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out. -`ui/dr-ui/ui/library.slint:1260` +`ui/dr-ui/ui/library.slint:1296` ### Find photographs with two people in them @@ -89,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot "Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar. -`ui/dr-ui/ui/library.slint:1928` +`ui/dr-ui/ui/library.slint:1978` ### Resize the thumbnails @@ -98,7 +107,7 @@ This replaced a double tap, which had no visible state and could take forty phot There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach. -`ui/dr-ui/ui/library.slint:2554` +`ui/dr-ui/ui/library.slint:2620` ### File photographs in a collection @@ -107,7 +116,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture. -`ui/dr-ui/ui/library.slint:2717` +`ui/dr-ui/ui/library.slint:2783` ### Open a photograph @@ -116,7 +125,7 @@ The selection is what the drag carries, which is why selecting several is worth A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush. -`ui/dr-ui/ui/library.slint:2972` +`ui/dr-ui/ui/library.slint:3051` ### Rate a photograph without opening it @@ -126,7 +135,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate A star has to take the press without it also reaching the cell, or every rating throws the user into develop. -`ui/dr-ui/ui/library.slint:3084` +`ui/dr-ui/ui/library.slint:3171` ### Drop the selection but keep selecting @@ -135,7 +144,7 @@ A star has to take the press without it also reaching the cell, or every rating Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away. -`ui/dr-ui/ui/library.slint:3747` +`ui/dr-ui/ui/library.slint:3879` ### Select everything the grid is showing @@ -144,4 +153,4 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it. -`ui/dr-ui/ui/library.slint:3764` +`ui/dr-ui/ui/library.slint:3896` diff --git a/docs/outstanding.md b/docs/outstanding.md index 1bc0473..18d056a 100644 --- a/docs/outstanding.md +++ b/docs/outstanding.md @@ -177,13 +177,18 @@ The Android app is not a stub — it builds an APK, runs the whole application, models, and has been measured on a tablet ([faces.md §12.1](faces.md), [technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it. -**FR-PLAT-AND-1 is tagged and should not be relied on.** The requirement demands that library access -be obtained *exclusively* through the Storage Access Framework. There is no SAF code: no -`ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, no `DocumentsContract`. The two tags -rest on a `SourceRef::Document` variant that nothing constructs and a volumes helper, which is the -"plumbing a future feature would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and -[code-health.md CH-4](code-health.md) both warn about. Android reaches a library through a Nextcloud -account or a folder, over paths, like the desktop. +**FR-PLAT-AND-1 is untagged, and what it was tagged for was intent rather than code.** +The requirement demands that library access be obtained *exclusively* through the Storage Access +Framework. There is no SAF code: no `ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, +no `DocumentsContract`. Its two tags rested on a `SourceRef::Document` variant constructed only +inside `#[cfg(test)]` — `LocalStorage::open` refuses it, and the test that proves so is named +`a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at` — and on +`dr_plat::imports_supported`, which *returns false on Android* and whose own documentation says it +"stops being false when a SAF implementation lands". The second tag documented the absence of the +thing it was counted as evidence for. Both have been removed; this is the "plumbing a future feature +would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and [code-health.md CH-4](code-health.md) both +warn about. Android reaches a library through a Nextcloud account or a folder, over paths, like the +desktop. That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a granted tree permission and marking images offline rather than deleting rows — cannot be built until @@ -220,10 +225,20 @@ the host is invisible to a sandboxed process as well. The fix is an `ashpd` dire `LocalStorage::grant`, not a change to the manifest. No Flatpak has been built here — `flatpak-builder` is not installed — so the permission set is reasoned, not observed. -**NFR-COMPAT-2 — distribution channels.** Unstated, and this is the requirement that makes the -others binding: §4.8 observes that the decision to publish on Play is what turns SAF from a -preference into a constraint. Spike S11, the Play permissions dry-run that would settle it, has not -run. Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android +**NFR-COMPAT-2 — distribution channels. Stated, which is all this requirement asks.** The paragraph +above cites [distribution.md](distribution.md) and it is the same document that answers this: §1 +names five channels and their state — Arch source package and Flatpak in tree, AppImage a v1 channel +whose recipe is not written, F-Droid a v1 channel not yet submitted, and Play explicitly **not** v1. +The requirement is to *state* the channels, and they are stated, including the deferral. + +What remains is the coupling the requirement points at rather than the statement it demands. §4.8 +observes that publishing on Play is what turns SAF from a preference into a constraint, and +distribution.md §6 argues the coupling runs the other way for this project — F-Droid asks nothing +that ARCH §6.9 does not already require. Spike S11, the Play permissions dry-run, has not run, and +until it does that argument is reasoned rather than confirmed. Two of the five channels also exist +as decisions rather than as recipes, and no Flatpak has been built here at all. + +Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android Dockerfile and are checked in CI against the built ELF, which is good — while the items the requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device @@ -255,9 +270,22 @@ on one control — the parameter slider in `adjust.slint` — and nothing is set all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run. -**NFR-A11Y-3 — Colour-independent status.** No compliance work found. This is cheap to satisfy while -a control is being written and expensive to retrofit across forty of them, which is an argument for -doing it as part of the NFR-A11Y-2 pass rather than after it. +**NFR-A11Y-3 — Colour-independent status.** Built where a control exists, and now tagged: the +clipping readout pairs a marker that appears or disappears with a figure in words, the rating strip +is a solid star against an outline in an achromatic palette, the pick/reject mark is a tick against +a cross, and the focus-peaking colour chips say "Red" and "Cyan" rather than showing swatches. Each +of those already carried the reasoning in a comment naming this requirement and simply had no +`TRACES` line. + +Two caveats, because the tag now says more than the evidence does. **Only the clipping clause has a +test** — `a_clipping_figure_distinguishes_none_from_nearly_none`, which pins `<0.1%` apart from `0%` +so the figure cannot contradict the lit marker beside it. The three Slint components are +inspected-and-argued, not asserted, and nothing would fail if a future edit made a star differ only +in tint. **And the requirement's first named example has no interface at all**: catalog colour +labels are a nullable `label INTEGER` column on the versions table and are set and shown nowhere, so +the clause about them is untestable rather than satisfied. That clause closes when the label UI is +built, not before, and it should be built with a shape from the outset — which is the same argument +as below, for doing this alongside NFR-A11Y-2 rather than after it. --- @@ -277,39 +305,54 @@ well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library int gap is narrower than it reads. It is the *first* build against a large remote library that pays, and that is the moment a new user meets. -**FR-CAT-13 — XMP interoperability, tagged and not met.** Read and write standard XMP sidecars. The -single tag sits on `keywords.rs`, which stores keywords; no XMP is parsed or written anywhere in the -tree, and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` -today"). Listed here rather than silently, because a tag makes a gap invisible and this one is -load-bearing for interoperating with the editors FR-CAT-14 imports from. +**FR-CAT-13 — XMP interoperability, untagged and not met.** Read and write standard XMP sidecars. +Its single tag sat on `keywords.rs`, which stores keywords in the catalog and mentions `dc:subject` +in a comment about what a keyword's text is *for*; no XMP is parsed or written anywhere in the tree, +and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` +today"). The tag has been removed — `dr-preset-xmp` is not the counter-example it looks like, being +a reader of Lightroom *presets* under FR-DEV-6, which is a different file and a different purpose. +Listed here rather than silently, because a tag makes a gap invisible and this one is load-bearing +for interoperating with the editors FR-CAT-14 imports from. --- -## 8. The performance targets are unverified, not unmet +## 8. The performance targets are half-verified, and the half that is left is the hard one -Eleven of the fifteen §4.1 targets carry no tag: NFR-P2, -P3, -P4, -P6, -P7, -P8, -P10, -P11, -P12, --P14, -P15. That is the uninteresting part of this section. +§8 and §4.1 both require the same thing in the same words: an automated benchmark suite against a +synthetic 50k catalog, run per commit, where **"a regression beyond a stated tolerance is a build +failure, not a notification."** For most of this project's life it did not exist — no `benches/`, no +criterion, no synthetic catalog, and three CI workflows that between them measured nothing. -The interesting part is that §8 and §4.1 both require the same thing, in the same words, and it does -not exist: an automated benchmark suite against a synthetic 50k catalog, run per commit, where **"a -regression beyond a stated tolerance is a build failure, not a notification."** There is no -`benches/` directory in the workspace, no criterion dependency, and no synthetic catalog. The three -CI workflows run `cargo fmt --check`, clippy, `cargo test --workspace`, a release build, an Android -cross-build and a layering check. None of them measures anything, so there is no baseline to -regress against and no tolerance to exceed. +**It exists now, for everything that does not need a frame.** [`tools/bench`](../tools/bench) builds +a deterministic 50,000-row catalog over a pool of a dozen real files, measures against it, and fails +the build on a violated budget or a drift past tolerance; +[`.gitea/workflows/benchmark.yml`](../.gitea/workflows/benchmark.yml) runs it on every push, and +[benchmarks.md](benchmarks.md) is the account of what it does and does not cover. **NFR-P1** and +**NFR-P3** are now genuinely gated, and R2's "catalog opens in under 2s" clause with them. -What does exist is narrower and genuinely good: `dr-gpu/examples/frame_budget` is a real instrument, -its results are committed in [frame-budget.md](frame-budget.md) with the machine and profile named, -and TD-4's before-and-after was measured with it. But it is run by hand — frame-budget.md's own -instruction is "rerun and diff this file" — and the guard version that does live in CI skips itself -where there is no GPU adapter, which the workflow notes is the normal case on a runner, while -asserting its CPU half only when `debug_assertions` is off, which a dev-profile `cargo test` is not. -In CI it therefore asserts approximately nothing. +Three qualifications, all of them stated in the harness itself rather than only here: -**The claim to take from this is precise.** Nothing here says the performance targets are missed. -Several are plausibly met. It says that if one were broken tomorrow, nobody would find out — which -is the failure mode §8 was written to prevent, and the reason it belongs in this document rather -than in a backlog. +- **The numbers have not been recorded yet.** Every `recorded` field in + [bench-baseline.json](bench-baseline.json) is `null`, deliberately: a fabricated baseline is worse + than none. Until `dr-bench record --reference` is run on the reference desktop and committed, the + budget gate works and the regression gate does not. +- **NFR-P7 and NFR-P8 are half-measured and are not tagged.** The export row covers the encode half + of the chain and no GPU render, so it can fail the requirement and cannot pass it. The memory row + covers a process holding the catalog and nothing else — no toolkit, no adapter — so it is the + catalog layer's share of the 500 MB rather than the figure NFR-P8 is about. Neither carries a + `TRACES:` tag, which is the point. +- **NFR-P8 needs a decision, not more code.** How much of its 500 MB belongs below the UI is + unstated, and until somebody says, the metric can record but not judge. [benchmarks.md](benchmarks.md) + also answers the question §4.1 raises about GPU memory — RSS cannot see device-local allocations + at all — and recommends restating the requirement as two figures. + +**What is left is the frame-timing half, and it is the hard one.** NFR-P2, -P4, -P5, -P6, -P9, -P10, +-P11, -P12, -P13, -P14 and -P15 all need a probe inside a running Slint application, a GPU adapter, +or both. `dr-gpu/examples/frame_budget` is a real instrument for the GPU part and its results are +committed in [frame-budget.md](frame-budget.md) with the machine and profile named — but it is run +by hand, and the guard version in CI skips itself where there is no adapter, which is the normal +case on a runner. So the claim to take from this section is now narrower than it was, and still +true: **a scroll that dropped to 30 fps tomorrow would reach a user before it reached CI.** --- @@ -320,12 +363,19 @@ fixed before spike S9", because S9 both validates R1 and calibrates what toleran The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at all — there is nothing a test could assert. -Worse, the matrix reports R1 as *covered*. Both of its tags are string literals inside the -traceability tool's own unit tests (`tools/traceability/src/lib.rs`), which the tool scans along with -everything else, because a fixture demonstrating tag extraction is indistinguishable from a tag. -NFR-OPS-1 is covered the same way, from a tag on `compute_coverage` — and no rotating, size-capped -on-disk log exists; logging goes to stderr and logcat. These are two of the cases -[CONTRIBUTING.md](../CONTRIBUTING.md) already warns about, now named. +The matrix used to report R1 as *covered*, and what covered it was two string literals: fixtures +inside the traceability tool's own unit tests, which the tool scans along with everything else, +because a fixture demonstrating tag extraction was indistinguishable from a tag. The extractor now +asks where the tag sits — a tag is the first word of a comment, not a string appearing anywhere on a +line — and R1 is untagged again, which is the honest reading while it has no acceptance criterion to +tag anything against. +NFR-OPS-1 was covered by tags that were real rather than fixtures, which is the worse case of the +two: one on `compute_coverage` and one on the gesture extractor, both on the traceability tool. A +coverage calculation and a documentation generator are not diagnostics under any reading, and the +requirement asks for a rotating, size-capped on-disk log in the XDG state directory, credential +redaction, and a consented diagnostics bundle. None of that exists — logging goes to stderr and +logcat — so both tags have been removed and NFR-OPS-1 is untagged. It is the case +[CONTRIBUTING.md](../CONTRIBUTING.md) warns about in its own words: a tag proves a tag exists. **R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated diff --git a/docs/requirements.md b/docs/requirements.md index b683e74..b238dc1 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -55,7 +55,7 @@ These are the user's stated requirements, restated as testable criteria. | **R2** | Efficient display of huge RAW libraries | A 50,000-image catalog scrolls at 60fps sustained, with a stated prefetch margin and cache-hit rate sufficient that no cell renders as a placeholder at a scroll velocity of *(figure TBD)* rows/second. Catalog opens in under 2s. | | **R3** | Make a RAW beautiful at 8-bit output | Full non-destructive develop chain at high internal precision, with camera input profiles (FR-DEV-3e) and a colour-managed path to 8/16-bit export. | | **R4** | HW acceleration and parallelism | All per-pixel work runs on GPU compute. CPU work (decode, I/O) is parallelised across cores. The UI executor never blocks on image work (NFR-ARCH-1). | -| **R5** | Work on downscaled proxies for display | Display pipeline operates at viewport resolution, not source resolution. Only visible tiles are computed; panning recomputes only newly exposed tiles. | +| **R5** | Work on downscaled proxies for display | Display pipeline operates at viewport resolution, not source resolution. The tiling clauses this criterion used to carry have been struck — see below. | | **R6** | Nextcloud integration | Browse, download, and upload images and edit metadata against a Nextcloud instance, offline-capable. | **On R1's tolerance.** An earlier draft required output to be *bit-identical* across platforms. @@ -72,6 +72,31 @@ Where genuine bit-identity is required — cache keys, edit-graph hashing (§5.2 applies to *integer* operations on CPU-side state, which are deterministic, never to GPU float results. +**On R5's tiling.** An earlier draft added two clauses to R5's criterion: *"only visible tiles are +computed; panning recomputes only newly exposed tiles"*. They have been struck, and the reason is +the same one FR-DSP-2 was rewritten for rather than implemented: +[frame-budget.md](frame-budget.md) measured it. + +R5's actual demand is met and tested. The display pipeline works at viewport resolution: +`Framing::view` shrinks the sampled region while the render target keeps its size, so zooming raises +the resolution the pipeline works at rather than magnifying pixels already drawn, and +`core/dr-gpu/tests/zoom_resolution.rs` establishes it as a pixel equality rather than an impression +of sharpness. + +Tiling is a different claim, and it was written in as though it were the mechanism by which the +first one is achieved. It is not. Recomputing the *entire* 4K viewport costs 4.5 ms of a 16 ms +budget, so a perfect tile cache saves at most that, in exchange for a cache keyed by +`(VersionId, tile, zoom, graph_hash_prefix)` that has to stay correct across every parameter change +in the graph — a large correctness surface bought with a small number. And for the one stage that +does miss the budget, tiling makes it worse: that stage is a convolution, and a tiled convolution +reads a halo per tile, so at the 52 px radius measured at 4K a 256 px tile would read (256+104)² +taps instead of 256², very nearly twice the work. + +The intent behind the struck clauses — that the display path must not do work proportional to the +source image — survives in the clause that remains, which is the honest statement of it. Tiling +stays where FR-DSP-2 puts it: a scheduling concern for export and thumbnailing, both of which +already run off the frame path. + --- ## 3. Functional requirements @@ -181,10 +206,34 @@ destructive action, since that figure is what tells the user whether they meant CR3), Nikon (NEF), Sony (ARW), Fujifilm (RAF, including X-Trans), Panasonic (RW2), Olympus (ORF), Adobe DNG. Additional formats are a coverage goal, not a launch blocker. -**FR-RAW-2 — Decoder abstraction.** RAW decoding sits behind a trait taking a `SourceRef` -(FR-CAT-1a), not a filesystem path, so the same decoder works over a local file, an Android SAF -document, or a byte range fetched from Nextcloud. A second implementation may be added for broader -camera coverage without changing callers (D2). +**FR-RAW-2 — Decoder abstraction.** RAW decoding takes **bytes**, never a filesystem path and never +a reference it would have to resolve. Resolving a `SourceRef` (FR-CAT-1a) to bytes is +`Storage::open`'s job and happens at the caller, so the same decoder works over a local file, an +Android SAF document, or a byte range fetched from Nextcloud. A second implementation may be added +for broader camera coverage without changing callers (D2). + +*On the change of mechanism.* This clause used to require "a trait taking a `SourceRef`". The +purpose — that no decoder API takes a path, so nothing in the decode path assumes a filesystem — +is met and is not in question: `dr_decode::decode` takes `&[u8]`, and there is no path-based entry +point in the crate. The mechanism was wrong, and stating it that way would have made the design +worse. + +A `SourceRef` is opaque by construction; the only thing that turns one into readable bytes is +`Storage`, in `platform/dr-plat`. A decoder taking a `SourceRef` would therefore have to take a +`Storage` alongside it, which moves retry, permission loss and remote fetching inside the decoder +and leaves it constructible only where a `Storage` exists. Bytes in, image out, is both narrower and +more portable: the decoder has no idea where its input came from, which is the property this +requirement is actually asking for. + +It also serves the Nextcloud case better rather than worse, which is the one a byte-oriented API +looks like it would lose. `dr_decode::HEADER_BYTES` declares how much of a file the decoder needs to +read metadata, and `import.rs` fetches exactly that range through `Storage::read_range` before +calling `dr_decode::metadata`. The decoder states its requirement and the storage layer satisfies +it; a decoder holding its own `SourceRef` would have had to implement the range policy itself. + +What is genuinely not built is the trait. There is one decoder, reached through free functions, so +"without changing callers" is a claim nothing yet tests. The clause stands as written and is +outstanding work, not a satisfied one. **FR-RAW-3 — Sensor data handling.** Correctly apply per-camera black/white levels, CFA pattern identification, and camera-native colour matrices. Demosaic quality shall be selectable, with at @@ -493,8 +542,24 @@ region. ### 3.6 Export -**FR-EXP-1 — Formats.** Export to JPEG, PNG, TIFF (8 and 16-bit), and AVIF or JPEG XL. Quality, -chroma subsampling, and bit depth are configurable. +**FR-EXP-1 — Formats.** Export to JPEG, PNG, and TIFF (8 and 16-bit). Quality, chroma subsampling, +and bit depth are configurable. + +**AVIF and JPEG XL are post-v1** and are not part of this requirement's acceptance. Either may still +be listed in the settings page before its encoder exists, on one condition: choosing it shall fail +with a typed error naming the format, never with a file. The test +`every_offered_format_either_encodes_or_explains_itself` walks every format the page offers and +enforces exactly that, so a format cannot be added to the picker and quietly reach an encoder that +does not handle it. + +*On splitting this requirement.* It read as one undifferentiated list — "JPEG, PNG, TIFF (8 and +16-bit), and AVIF or JPEG XL" — which left it neither met nor unmet. Three formats, both TIFF +depths, and the configurability clause are built, encode, embed their profile, and are tested; the +fourth item is a deliberate deferral, and the encoders for it are the two with the least settled +library support. Fused into one sentence, the only choices were a tag asserting something untrue or +no tag at all, and the second is the worse of the two: it would have removed the register's record +of four-fifths of a requirement that is finished. The deferral is now stated where it can be read as +a decision rather than inferred from an error variant. **FR-EXP-2 — Colour space.** Export in a selectable output colour space (sRGB, Display P3, Adobe RGB, ProPhoto), with the correct ICC profile embedded. @@ -1305,13 +1370,25 @@ These are targets to design against and measure, on the reference desktop | **NFR-P14** | Focus peaking overlay ready | < 100 ms after preview | < 150 ms | | **NFR-P15** | Drawn mask stroke → visible (ARCH §6.11) | < 16 ms, no cursor lag | < 16 ms | | **NFR-P10** | Touch gesture → visual response | < 16 ms | < 16 ms | -| **NFR-P11** | Layout class transition (window resize) | No dropped frames, no state loss | n/a | +| **NFR-P11** | Layout class transition (window resize) | No dropped frames. No loss of *photographic* state: the open image and version, the selection, scroll position, the in-progress edit and its undo history, and the current mode. Panel disclosure is explicitly exempt — see below | n/a | | **NFR-P12** | Warm-start shader pipeline setup (cached) | < 100 ms | < 100 ms | Every target above requires a stated measurement method, workload, and pass threshold before it is testable. NFR-P8 in particular must state whether it measures RSS inclusive or exclusive of GPU allocations, and whether it holds after SQLite's page cache warms on a 50k catalog. +**On what NFR-P11 means by state.** It said "no state loss", which the implementation contradicts on +purpose, so the requirement has been made specific rather than left to be read as forbidding +something it should not. `apply_layout_class` discards the user's panel open/closed choices when the +class changes, and the argument for that is sound: a choice made in landscape answers a different +question from the one portrait asks, and carrying it across is how a photographer ends up with a +232 px sidebar on a screen with no room for it and no memory of having asked for it. Panel +disclosure is a *default*, re-derived per class, with the user's disagreement remembered only within +the class where it was expressed. + +Everything the photographer produced or navigated to is a different matter, and none of it may be +touched by a resize. That is the list in the criterion, and it is the testable half. + **Performance regressions fail the build.** §9's benchmark suite runs per-commit; a regression beyond a stated tolerance is a build failure, not a notification. Performance work rots otherwise. diff --git a/docs/technical-debt.md b/docs/technical-debt.md index fb972b7..7a00b0b 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -297,6 +297,152 @@ millisecond for the `point` and `all` chains, and the codegen tests still pass b --- +## TD-6 — The quietest ink does not reach WCAG AA, and the rule does not reach 3:1 + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "non-canvas UI meets WCAG AA contrast". + +### What it does + +`style.yaml` sets three inks and four surfaces. Measured as WCAG 2 contrast ratios (sRGB relative +luminance, the standard formula), against the surfaces each ink is actually drawn on: + +| ink | on `ground` | on `surface` | on `surface-raised` | on `hover` | on `selected` | +|---|---|---|---|---|---| +| `ink` #EDEEF0 | 16.02 | 14.69 | 13.03 | 11.39 | 9.68 | +| `ink-dim` #9EA1A6 | 7.18 | 6.58 | 5.84 | 5.10 | **4.34** | +| `ink-faint` #71747A | **3.97** | **3.64** | **3.23** | **2.82** | **2.40** | +| `warn-ink` #C9A05A | 7.67 | 7.03 | 6.24 | 5.45 | 4.64 | +| `rule` #323438 | **1.49** | **1.37** | **1.21** | **1.06** | **1.11** | + +Every text size in the application is 11px, 13px, 17px or 24px, and WCAG's "large text" relief +begins at 18.66px bold or 24px regular — so all four of those thresholds are the normal-text one, +**4.5:1**, except the masthead. Bold entries fail it. + +The inverted cases pass and are worth stating so nobody re-measures them: `ground` on `active` +(#FFFFFF) is 18.60, on `active-dim` 11.20, on `active-pressed` 5.95, on `selected-ring` 13.02. The +near-white fills that `Button.primary`, `FilterChip.active` and the held tool-rail entry use are +the *best*-contrasting text in the interface, not the worst. + +So the failures are exactly two, and neither is where one would guess: + +- **`ink-faint` reaches 4.5:1 nowhere at all.** It is the ink for `Caption`, `PanelHeading`, + `Disclosure`, `Value`'s placeholder state and `FilterChip`'s count — every hint, every section + name, every "3 photographs" under a title. +- **`rule` reaches 3:1 nowhere.** WCAG 1.4.11 asks 3:1 of the boundary of a control the user must + perceive, and `rule` is the border of every `Button`, `Field`, `Panel`, `ChoiceChip` and + `IconButton`. An unfilled secondary button is a 1.4:1 outline on a 1.2:1 background. + +`ink-dim` on `selected` at 4.34 is a third case, marginal enough that a two-point lift fixes it. + +### Why + +Not an oversight — the direct consequence of the palette's own argument, which `style.yaml`'s +preamble makes at length and correctly. The chrome is deliberately quiet because a bright surround +biases how a photograph is judged, and hue is banned outright because an accent beside the image +shifts the perception of nearby colours. What is left to signal with is luminance, and the palette +spends its luminance range on the *photograph*, keeping the chrome inside a narrow band above the +ground. + +A narrow band is precisely what a contrast ratio measures. `ink-faint` exists to be skipped by the +reader who did not stop to look; that is a real design intent, and "text you are meant to skip" +and "text everyone can read" are in genuine tension rather than one being a mistake. + +### What it costs + +The photographer who cannot read a caption cannot read *any* caption, on any screen — this is one +token, so it fails everywhere at once. The hints under the settings switches say what a setting +costs, the section names say what a panel is, and the counts say how big a filter is. None of it is +decorative. + +### Paying it off + +Two token changes, and the second is the awkward one. + +`ink-faint` needs roughly #8A8D93 to clear 4.5:1 against `surface-raised`, the darkest surface it +is drawn on that matters — which puts it about where `ink-dim` sits today and collapses the +three-ink scale to two. So the real fix is to re-derive all three inks against the surfaces rather +than to nudge one: the scale wants to start higher and keep its steps, not compress. + +`rule` needs about #4A4D52 for 3:1 against `surface`. That is a visibly stronger line, and the +preamble's "instrument rather than absence" reasoning applies to it as much as to the greys — this +is a look change, not a number change, and it should be looked at rather than computed. + +Both are decisions about how the application appears next to a photograph, which is the one thing +this palette was designed around. They want a screenshot and an opinion, not a patch. + +**Done when:** every `Theme` ink reaches 4.5:1 against every surface it is drawn on, `rule` reaches +3:1 against `surface` and `surface-raised`, and a test recomputes those ratios from `style.yaml` so +the next palette edit cannot quietly undo it. The table above is the baseline to compare against. + +### Not in scope + +The histogram's `plot-*` inks (2.36 for `plot-luma` on `ground`) are drawn *on* the canvas, and +NFR-A11Y-2 scopes contrast to non-canvas UI. NFR-A11Y-3 covers what those need instead, and is +already met — the readouts name the channel in words. + +--- + +## TD-7 — Platform font scaling is not honoured + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "platform font scaling is honoured +without clipping". + +### What it does + +Nothing at all, which is the entry. Every type size is a constant in `style.yaml` — 11, 13, 17, 24 +— read as `Theme.text-sm` and friends at 65 call sites, and there is no multiplier anywhere between +the platform's font-size preference and those numbers. `scale_factor()` is read in `display_ui.rs` +and in `lib.rs`, but only to size the canvas in physical pixels for the render; it is display DPI, +which Slint already applies to logical lengths, and it is not the user's text-size setting. A +photographer who sets 130% text on GNOME or Android gets an application that ignores it. + +### Why + +Because honouring it is not a multiplier, and pretending it is would be worse than not doing it. + +The layout is built on constants that are not derived from the type size: `control-height` 28, +`touch-target` 44, `row-height` 26, `rail-entry-height` 54, `panel-width` 360, and a dozen fixed +heights written at their call sites — `ParamSlider`'s 46px, the folder picker's 220px box, the +readout column's 30px. Scaling the type alone clips against every one of them, silently, because +Slint elides rather than errors. `SwatchSlider` is the sharpest case: 12 hue bands × 3 channels in +a 360px column, sized so that a track and a swatch and a three-character readout fit on one line. + +And the failure is invisible to the person shipping it. The requirement's own phrase is "without +clipping", and clipping is exactly what a screenshot at 100% cannot show — the memory note on +verifying Slint changes exists because these files have a history of compiling, rendering and being +wrong. + +### What it costs + +The user for whom this matters most is not the screen-reader user the rest of this branch serves — +it is the one with usable but poor sight, who reads the interface and needs it larger. The +application is unusable to them at any setting, and there is no partial credit: text scaling is a +system-wide preference, so an app that ignores it is the one thing on the desktop that did. + +### Paying it off + +In the order the pieces depend on each other: + +1. **A scale token.** `Theme.text-sm` and the rest become `base × Theme.type-scale`, with the + scale an `in-out` property Rust writes at startup from the platform. `build.rs` already emits + `in-out` tokens under `live-style`, so the codegen half of this exists and is proven — that + feature is the mechanism, one line from being general. +2. **A source for the number.** GNOME publishes `text-scaling-factor` over the settings portal; + Android has `Configuration.fontScale` through JNI, beside the calls `lib.rs` already makes for + `ACTION_VIEW`. Both want a default of 1.0 and a sane clamp — 0.8 to 2.0 — because a user who has + set 300% for a phone launcher has not asked for a 72px slider readout. +3. **The constants that are not type.** Every fixed height a *label* sits inside has to follow the + scale; every touch target must not shrink and need not grow. That is the work, and it is where + the 46px and 220px literals get read one at a time. +4. **Evidence.** Screenshots at 1.0, 1.3 and 2.0 of the develop column, the settings page and the + colour mixer — the three densest layouts — because "without clipping" is a claim about the + worst case and nothing else will show it. + +**Done when:** the develop column, the settings page and the colour mixer render at a 2.0 scale +with no elided label and no touch target under 44 logical pixels. + +--- + ## Related, and deliberately not here The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* diff --git a/docs/traceability.md b/docs/traceability.md index 9846d0e..a9f1009 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,19 +9,19 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 312 | -| TRACES tags found | 1008 | +| Source files scanned | 328 | +| TRACES tags found | 1025 | | Requirements defined | 179 | -| Requirements covered | 122 | -| **Coverage** | **68.2%** (122/179) | +| Requirements covered | 125 | +| **Coverage** | **69.8%** (125/179) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 94 | 124 | -| NFR | 24 | 49 | -| R | 4 | 6 | +| FR | 93 | 124 | +| NFR | 29 | 49 | +| R | 3 | 6 | ## Orphan tags @@ -33,135 +33,139 @@ _None._ | ID | Tagged in | |---|---| -| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`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-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`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), [`tools/traceability/src/lib.rs:481`](../tools/traceability/src/lib.rs#L481), [`tools/traceability/src/lib.rs:513`](../tools/traceability/src/lib.rs#L513), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.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:206`](../core/dr-types/src/settings.rs#L206), [`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:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1230`](../ui/dr-ui/src/lib.rs#L1230), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1134`](../ui/dr-ui/ui/library.slint#L1134), [`ui/dr-ui/ui/library.slint:868`](../ui/dr-ui/ui/library.slint#L868), [`ui/dr-ui/ui/library.slint:922`](../ui/dr-ui/ui/library.slint#L922) | -| 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:1230`](../ui/dr-ui/src/lib.rs#L1230), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`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-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`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.rs:1`](../ui/dr-ui/src/library.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:206`](../core/dr-types/src/settings.rs#L206), [`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:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1231`](../ui/dr-ui/src/lib.rs#L1231), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1136`](../ui/dr-ui/ui/library.slint#L1136), [`ui/dr-ui/ui/library.slint:870`](../ui/dr-ui/ui/library.slint#L870), [`ui/dr-ui/ui/library.slint:924`](../ui/dr-ui/ui/library.slint#L924) | +| 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:1231`](../ui/dr-ui/src/lib.rs#L1231), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119) | -| 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:720`](../core/dr-catalog/src/schema.rs#L720), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:563`](../core/dr-sync/src/scan.rs#L563), [`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:1232`](../ui/dr-ui/src/collections_ui.rs#L1232), [`ui/dr-ui/src/collections_ui.rs:2105`](../ui/dr-ui/src/collections_ui.rs#L2105), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:192`](../ui/dr-ui/src/library.rs#L192), [`ui/dr-ui/src/library.rs:241`](../ui/dr-ui/src/library.rs#L241), [`ui/dr-ui/src/library.rs:4023`](../ui/dr-ui/src/library.rs#L4023), [`ui/dr-ui/src/library.rs:4057`](../ui/dr-ui/src/library.rs#L4057), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:778`](../ui/dr-ui/src/library_ui.rs#L778), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:621`](../ui/dr-ui/ui/collections.slint#L621) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:720`](../core/dr-catalog/src/schema.rs#L720), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:563`](../core/dr-sync/src/scan.rs#L563), [`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:1283`](../ui/dr-ui/src/collections_ui.rs#L1283), [`ui/dr-ui/src/collections_ui.rs:2189`](../ui/dr-ui/src/collections_ui.rs#L2189), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:192`](../ui/dr-ui/src/library.rs#L192), [`ui/dr-ui/src/library.rs:241`](../ui/dr-ui/src/library.rs#L241), [`ui/dr-ui/src/library.rs:4023`](../ui/dr-ui/src/library.rs#L4023), [`ui/dr-ui/src/library.rs:4057`](../ui/dr-ui/src/library.rs#L4057), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:778`](../ui/dr-ui/src/library_ui.rs#L778), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:621`](../ui/dr-ui/ui/collections.slint#L621) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`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), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | -| 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:481`](../tools/traceability/src/lib.rs#L481) | -| 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:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3731`](../ui/dr-ui/src/library_ui.rs#L3731), [`ui/dr-ui/src/library_ui.rs:4749`](../ui/dr-ui/src/library_ui.rs#L4749), [`ui/dr-ui/ui/app.slint:387`](../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | +| 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) | +| 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:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3765`](../ui/dr-ui/src/library_ui.rs#L3765), [`ui/dr-ui/src/library_ui.rs:4785`](../ui/dr-ui/src/library_ui.rs#L4785), [`ui/dr-ui/ui/app.slint:403`](../ui/dr-ui/ui/app.slint#L403), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | | 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:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:222`](../ui/dr-ui/src/library.rs#L222), [`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:1138`](../core/dr-catalog/src/schema.rs#L1138), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`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:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1525`](../ui/dr-ui/src/collections_ui.rs#L1525), [`ui/dr-ui/src/collections_ui.rs:1631`](../ui/dr-ui/src/collections_ui.rs#L1631), [`ui/dr-ui/src/collections_ui.rs:1680`](../ui/dr-ui/src/collections_ui.rs#L1680), [`ui/dr-ui/src/collections_ui.rs:244`](../ui/dr-ui/src/collections_ui.rs#L244), [`ui/dr-ui/src/collections_ui.rs:341`](../ui/dr-ui/src/collections_ui.rs#L341), [`ui/dr-ui/src/collections_ui.rs:90`](../ui/dr-ui/src/collections_ui.rs#L90), [`ui/dr-ui/src/library.rs:4071`](../ui/dr-ui/src/library.rs#L4071), [`ui/dr-ui/src/library_ui.rs:653`](../ui/dr-ui/src/library_ui.rs#L653), [`ui/dr-ui/src/library_ui.rs:6676`](../ui/dr-ui/src/library_ui.rs#L6676), [`ui/dr-ui/src/library_ui.rs:6687`](../ui/dr-ui/src/library_ui.rs#L6687), [`ui/dr-ui/src/library_ui.rs:6700`](../ui/dr-ui/src/library_ui.rs#L6700), [`ui/dr-ui/src/library_ui.rs:6715`](../ui/dr-ui/src/library_ui.rs#L6715), [`ui/dr-ui/src/library_ui.rs:6724`](../ui/dr-ui/src/library_ui.rs#L6724), [`ui/dr-ui/ui/app.slint:323`](../ui/dr-ui/ui/app.slint#L323), [`ui/dr-ui/ui/app.slint:522`](../ui/dr-ui/ui/app.slint#L522), [`ui/dr-ui/ui/library.slint:1206`](../ui/dr-ui/ui/library.slint#L1206), [`ui/dr-ui/ui/library.slint:1230`](../ui/dr-ui/ui/library.slint#L1230), [`ui/dr-ui/ui/library.slint:1283`](../ui/dr-ui/ui/library.slint#L1283), [`ui/dr-ui/ui/library.slint:19`](../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:3816`](../ui/dr-ui/ui/library.slint#L3816) | -| 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:635`](../core/dr-catalog/src/schema.rs#L635), [`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.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:4464`](../ui/dr-ui/src/library.rs#L4464), [`ui/dr-ui/src/library_ui.rs:337`](../ui/dr-ui/src/library_ui.rs#L337), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:5416`](../ui/dr-ui/src/library_ui.rs#L5416), [`ui/dr-ui/src/library_ui.rs:5469`](../ui/dr-ui/src/library_ui.rs#L5469), [`ui/dr-ui/src/library_ui.rs:6816`](../ui/dr-ui/src/library_ui.rs#L6816), [`ui/dr-ui/ui/app.slint:335`](../ui/dr-ui/ui/app.slint#L335), [`ui/dr-ui/ui/app.slint:522`](../ui/dr-ui/ui/app.slint#L522), [`ui/dr-ui/ui/app.slint:818`](../ui/dr-ui/ui/app.slint#L818), [`ui/dr-ui/ui/library.slint:110`](../ui/dr-ui/ui/library.slint#L110), [`ui/dr-ui/ui/library.slint:1379`](../ui/dr-ui/ui/library.slint#L1379), [`ui/dr-ui/ui/library.slint:3816`](../ui/dr-ui/ui/library.slint#L3816), [`ui/dr-ui/ui/settings.slint:150`](../ui/dr-ui/ui/settings.slint#L150) | -| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1794`](../ui/dr-ui/src/collections_ui.rs#L1794), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:928`](../ui/dr-ui/src/collections_ui.rs#L928), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4269`](../ui/dr-ui/src/library.rs#L4269), [`ui/dr-ui/src/library.rs:4295`](../ui/dr-ui/src/library.rs#L4295), [`ui/dr-ui/src/library.rs:4350`](../ui/dr-ui/src/library.rs#L4350), [`ui/dr-ui/src/library_ui.rs:3593`](../ui/dr-ui/src/library_ui.rs#L3593), [`ui/dr-ui/src/library_ui.rs:4320`](../ui/dr-ui/src/library_ui.rs#L4320), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2667`](../ui/dr-ui/ui/library.slint#L2667), [`ui/dr-ui/ui/library.slint:3794`](../ui/dr-ui/ui/library.slint#L3794), [`ui/dr-ui/ui/library.slint:887`](../ui/dr-ui/ui/library.slint#L887) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`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:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`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), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:3831`](../ui/dr-ui/src/develop.rs#L3831), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1013`](../ui/dr-ui/src/lib.rs#L1013), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/lib.rs:1999`](../ui/dr-ui/src/lib.rs#L1999), [`ui/dr-ui/src/lib.rs:2134`](../ui/dr-ui/src/lib.rs#L2134), [`ui/dr-ui/src/lib.rs:588`](../ui/dr-ui/src/lib.rs#L588), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library_ui.rs:5041`](../ui/dr-ui/src/library_ui.rs#L5041), [`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:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`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:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`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:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:3229`](../ui/dr-ui/src/develop.rs#L3229), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:161`](../ui/dr-ui/src/library.rs#L161), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:266`](../ui/dr-ui/src/library.rs#L266), [`ui/dr-ui/src/library.rs:4572`](../ui/dr-ui/src/library.rs#L4572), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:807`](../ui/dr-ui/src/library.rs#L807), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1636`](../ui/dr-ui/src/library_ui.rs#L1636), [`ui/dr-ui/src/library_ui.rs:1680`](../ui/dr-ui/src/library_ui.rs#L1680), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:1790`](../ui/dr-ui/src/library_ui.rs#L1790), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2436`](../ui/dr-ui/src/library_ui.rs#L2436), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:2874`](../ui/dr-ui/src/library_ui.rs#L2874), [`ui/dr-ui/src/library_ui.rs:3096`](../ui/dr-ui/src/library_ui.rs#L3096), [`ui/dr-ui/src/library_ui.rs:3374`](../ui/dr-ui/src/library_ui.rs#L3374), [`ui/dr-ui/src/library_ui.rs:3462`](../ui/dr-ui/src/library_ui.rs#L3462), [`ui/dr-ui/src/library_ui.rs:3644`](../ui/dr-ui/src/library_ui.rs#L3644), [`ui/dr-ui/src/library_ui.rs:3758`](../ui/dr-ui/src/library_ui.rs#L3758), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/library_ui.rs:5514`](../ui/dr-ui/src/library_ui.rs#L5514), [`ui/dr-ui/src/library_ui.rs:5629`](../ui/dr-ui/src/library_ui.rs#L5629), [`ui/dr-ui/src/presets.rs:446`](../ui/dr-ui/src/presets.rs#L446), [`ui/dr-ui/src/presets.rs:458`](../ui/dr-ui/src/presets.rs#L458), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.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:1138`](../core/dr-catalog/src/schema.rs#L1138), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`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:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1579`](../ui/dr-ui/src/collections_ui.rs#L1579), [`ui/dr-ui/src/collections_ui.rs:1685`](../ui/dr-ui/src/collections_ui.rs#L1685), [`ui/dr-ui/src/collections_ui.rs:1734`](../ui/dr-ui/src/collections_ui.rs#L1734), [`ui/dr-ui/src/collections_ui.rs:238`](../ui/dr-ui/src/collections_ui.rs#L238), [`ui/dr-ui/src/collections_ui.rs:335`](../ui/dr-ui/src/collections_ui.rs#L335), [`ui/dr-ui/src/collections_ui.rs:87`](../ui/dr-ui/src/collections_ui.rs#L87), [`ui/dr-ui/src/library.rs:4071`](../ui/dr-ui/src/library.rs#L4071), [`ui/dr-ui/src/library_ui.rs:653`](../ui/dr-ui/src/library_ui.rs#L653), [`ui/dr-ui/src/library_ui.rs:6717`](../ui/dr-ui/src/library_ui.rs#L6717), [`ui/dr-ui/src/library_ui.rs:6728`](../ui/dr-ui/src/library_ui.rs#L6728), [`ui/dr-ui/src/library_ui.rs:6741`](../ui/dr-ui/src/library_ui.rs#L6741), [`ui/dr-ui/src/library_ui.rs:6756`](../ui/dr-ui/src/library_ui.rs#L6756), [`ui/dr-ui/src/library_ui.rs:6765`](../ui/dr-ui/src/library_ui.rs#L6765), [`ui/dr-ui/ui/app.slint:324`](../ui/dr-ui/ui/app.slint#L324), [`ui/dr-ui/ui/app.slint:538`](../ui/dr-ui/ui/app.slint#L538), [`ui/dr-ui/ui/library.slint:1242`](../ui/dr-ui/ui/library.slint#L1242), [`ui/dr-ui/ui/library.slint:1266`](../ui/dr-ui/ui/library.slint#L1266), [`ui/dr-ui/ui/library.slint:1319`](../ui/dr-ui/ui/library.slint#L1319), [`ui/dr-ui/ui/library.slint:19`](../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:3948`](../ui/dr-ui/ui/library.slint#L3948) | +| 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:635`](../core/dr-catalog/src/schema.rs#L635), [`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.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:4464`](../ui/dr-ui/src/library.rs#L4464), [`ui/dr-ui/src/library_ui.rs:337`](../ui/dr-ui/src/library_ui.rs#L337), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:5452`](../ui/dr-ui/src/library_ui.rs#L5452), [`ui/dr-ui/src/library_ui.rs:5505`](../ui/dr-ui/src/library_ui.rs#L5505), [`ui/dr-ui/src/library_ui.rs:6857`](../ui/dr-ui/src/library_ui.rs#L6857), [`ui/dr-ui/ui/app.slint:336`](../ui/dr-ui/ui/app.slint#L336), [`ui/dr-ui/ui/app.slint:538`](../ui/dr-ui/ui/app.slint#L538), [`ui/dr-ui/ui/app.slint:841`](../ui/dr-ui/ui/app.slint#L841), [`ui/dr-ui/ui/library.slint:110`](../ui/dr-ui/ui/library.slint#L110), [`ui/dr-ui/ui/library.slint:1429`](../ui/dr-ui/ui/library.slint#L1429), [`ui/dr-ui/ui/library.slint:3948`](../ui/dr-ui/ui/library.slint#L3948), [`ui/dr-ui/ui/settings.slint:150`](../ui/dr-ui/ui/settings.slint#L150) | +| 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:1073`](../ui/dr-ui/src/collections_ui.rs#L1073), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1598`](../ui/dr-ui/src/collections_ui.rs#L1598), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/collections_ui.rs:1913`](../ui/dr-ui/src/collections_ui.rs#L1913), [`ui/dr-ui/src/collections_ui.rs:1973`](../ui/dr-ui/src/collections_ui.rs#L1973), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:365`](../ui/dr-ui/src/collections_ui.rs#L365), [`ui/dr-ui/src/collections_ui.rs:569`](../ui/dr-ui/src/collections_ui.rs#L569), [`ui/dr-ui/src/collections_ui.rs:965`](../ui/dr-ui/src/collections_ui.rs#L965), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4269`](../ui/dr-ui/src/library.rs#L4269), [`ui/dr-ui/src/library.rs:4295`](../ui/dr-ui/src/library.rs#L4295), [`ui/dr-ui/src/library.rs:4350`](../ui/dr-ui/src/library.rs#L4350), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4354`](../ui/dr-ui/src/library_ui.rs#L4354), [`ui/dr-ui/ui/app.slint:327`](../ui/dr-ui/ui/app.slint#L327), [`ui/dr-ui/ui/app.slint:533`](../ui/dr-ui/ui/app.slint#L533), [`ui/dr-ui/ui/app.slint:562`](../ui/dr-ui/ui/app.slint#L562), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2733`](../ui/dr-ui/ui/library.slint#L2733), [`ui/dr-ui/ui/library.slint:3926`](../ui/dr-ui/ui/library.slint#L3926), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`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:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`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), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:3831`](../ui/dr-ui/src/develop.rs#L3831), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1014`](../ui/dr-ui/src/lib.rs#L1014), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:2000`](../ui/dr-ui/src/lib.rs#L2000), [`ui/dr-ui/src/lib.rs:2135`](../ui/dr-ui/src/lib.rs#L2135), [`ui/dr-ui/src/lib.rs:589`](../ui/dr-ui/src/lib.rs#L589), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library_ui.rs:5077`](../ui/dr-ui/src/library_ui.rs#L5077), [`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:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`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:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`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:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:3229`](../ui/dr-ui/src/develop.rs#L3229), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:161`](../ui/dr-ui/src/library.rs#L161), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:266`](../ui/dr-ui/src/library.rs#L266), [`ui/dr-ui/src/library.rs:4572`](../ui/dr-ui/src/library.rs#L4572), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:807`](../ui/dr-ui/src/library.rs#L807), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1051`](../ui/dr-ui/src/library_ui.rs#L1051), [`ui/dr-ui/src/library_ui.rs:1642`](../ui/dr-ui/src/library_ui.rs#L1642), [`ui/dr-ui/src/library_ui.rs:1686`](../ui/dr-ui/src/library_ui.rs#L1686), [`ui/dr-ui/src/library_ui.rs:1702`](../ui/dr-ui/src/library_ui.rs#L1702), [`ui/dr-ui/src/library_ui.rs:1796`](../ui/dr-ui/src/library_ui.rs#L1796), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2470`](../ui/dr-ui/src/library_ui.rs#L2470), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:2908`](../ui/dr-ui/src/library_ui.rs#L2908), [`ui/dr-ui/src/library_ui.rs:3130`](../ui/dr-ui/src/library_ui.rs#L3130), [`ui/dr-ui/src/library_ui.rs:3408`](../ui/dr-ui/src/library_ui.rs#L3408), [`ui/dr-ui/src/library_ui.rs:3496`](../ui/dr-ui/src/library_ui.rs#L3496), [`ui/dr-ui/src/library_ui.rs:3678`](../ui/dr-ui/src/library_ui.rs#L3678), [`ui/dr-ui/src/library_ui.rs:3792`](../ui/dr-ui/src/library_ui.rs#L3792), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/library_ui.rs:5550`](../ui/dr-ui/src/library_ui.rs#L5550), [`ui/dr-ui/src/library_ui.rs:5665`](../ui/dr-ui/src/library_ui.rs#L5665), [`ui/dr-ui/src/presets.rs:446`](../ui/dr-ui/src/presets.rs#L446), [`ui/dr-ui/src/presets.rs:458`](../ui/dr-ui/src/presets.rs#L458), [`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:121`](../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:120`](../ui/dr-ui/src/develop.rs#L120), [`ui/dr-ui/src/develop.rs:129`](../ui/dr-ui/src/develop.rs#L129), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:1968`](../ui/dr-ui/src/develop.rs#L1968), [`ui/dr-ui/src/develop.rs:688`](../ui/dr-ui/src/develop.rs#L688), [`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:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2106`](../ui/dr-ui/src/lib.rs#L2106), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:120`](../ui/dr-ui/src/develop.rs#L120), [`ui/dr-ui/src/develop.rs:129`](../ui/dr-ui/src/develop.rs#L129), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:1968`](../ui/dr-ui/src/develop.rs#L1968), [`ui/dr-ui/src/develop.rs:688`](../ui/dr-ui/src/develop.rs#L688), [`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:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2107`](../ui/dr-ui/src/lib.rs#L2107), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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.rs:287`](../ui/dr-ui/src/library.rs#L287), [`ui/dr-ui/src/library.rs:317`](../ui/dr-ui/src/library.rs#L317), [`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:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-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/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| 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.rs:2954`](../ui/dr-ui/src/develop.rs#L2954), [`ui/dr-ui/src/develop.rs:2967`](../ui/dr-ui/src/develop.rs#L2967), [`ui/dr-ui/src/develop.rs:3012`](../ui/dr-ui/src/develop.rs#L3012), [`ui/dr-ui/src/develop.rs:3023`](../ui/dr-ui/src/develop.rs#L3023), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3046`](../ui/dr-ui/src/develop.rs#L3046), [`ui/dr-ui/src/develop.rs:5929`](../ui/dr-ui/src/develop.rs#L5929), [`ui/dr-ui/src/develop.rs:5993`](../ui/dr-ui/src/develop.rs#L5993), [`ui/dr-ui/src/develop.rs:6016`](../ui/dr-ui/src/develop.rs#L6016), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/develop.rs:752`](../ui/dr-ui/src/develop.rs#L752), [`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:541`](../ui/dr-ui/src/histogram.rs#L541), [`ui/dr-ui/src/histogram.rs:562`](../ui/dr-ui/src/histogram.rs#L562), [`ui/dr-ui/src/histogram.rs:590`](../ui/dr-ui/src/histogram.rs#L590), [`ui/dr-ui/src/histogram.rs:618`](../ui/dr-ui/src/histogram.rs#L618), [`ui/dr-ui/src/histogram.rs:655`](../ui/dr-ui/src/histogram.rs#L655), [`ui/dr-ui/src/lib.rs:1548`](../ui/dr-ui/src/lib.rs#L1548), [`ui/dr-ui/src/lib.rs:1626`](../ui/dr-ui/src/lib.rs#L1626), [`ui/dr-ui/src/lib.rs:1725`](../ui/dr-ui/src/lib.rs#L1725), [`ui/dr-ui/src/lib.rs:1766`](../ui/dr-ui/src/lib.rs#L1766), [`ui/dr-ui/src/lib.rs:3016`](../ui/dr-ui/src/lib.rs#L3016), [`ui/dr-ui/src/lib.rs:348`](../ui/dr-ui/src/lib.rs#L348), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1775`](../ui/dr-ui/ui/app.slint#L1775), [`ui/dr-ui/ui/app.slint:2353`](../ui/dr-ui/ui/app.slint#L2353), [`ui/dr-ui/ui/app.slint:88`](../ui/dr-ui/ui/app.slint#L88), [`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) | +| 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.rs:2954`](../ui/dr-ui/src/develop.rs#L2954), [`ui/dr-ui/src/develop.rs:2967`](../ui/dr-ui/src/develop.rs#L2967), [`ui/dr-ui/src/develop.rs:3012`](../ui/dr-ui/src/develop.rs#L3012), [`ui/dr-ui/src/develop.rs:3023`](../ui/dr-ui/src/develop.rs#L3023), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3046`](../ui/dr-ui/src/develop.rs#L3046), [`ui/dr-ui/src/develop.rs:5929`](../ui/dr-ui/src/develop.rs#L5929), [`ui/dr-ui/src/develop.rs:5993`](../ui/dr-ui/src/develop.rs#L5993), [`ui/dr-ui/src/develop.rs:6016`](../ui/dr-ui/src/develop.rs#L6016), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/develop.rs:752`](../ui/dr-ui/src/develop.rs#L752), [`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:1549`](../ui/dr-ui/src/lib.rs#L1549), [`ui/dr-ui/src/lib.rs:1627`](../ui/dr-ui/src/lib.rs#L1627), [`ui/dr-ui/src/lib.rs:1726`](../ui/dr-ui/src/lib.rs#L1726), [`ui/dr-ui/src/lib.rs:1767`](../ui/dr-ui/src/lib.rs#L1767), [`ui/dr-ui/src/lib.rs:3017`](../ui/dr-ui/src/lib.rs#L3017), [`ui/dr-ui/src/lib.rs:349`](../ui/dr-ui/src/lib.rs#L349), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1807`](../ui/dr-ui/ui/app.slint#L1807), [`ui/dr-ui/ui/app.slint:2385`](../ui/dr-ui/ui/app.slint#L2385), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`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) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:412`](../core/dr-catalog/src/schema.rs#L412), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:202`](../ui/dr-ui/src/library.rs#L202), [`ui/dr-ui/src/library.rs:5173`](../ui/dr-ui/src/library.rs#L5173) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:480`](../core/dr-catalog/src/schema.rs#L480), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069), [`ui/dr-ui/ui/settings.slint:419`](../ui/dr-ui/ui/settings.slint#L419), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | -| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:276`](../ui/dr-ui/ui/identity.slint#L276) | +| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`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-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:283`](../ui/dr-ui/ui/identity.slint#L283) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`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:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`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:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1950`](../ui/dr-ui/src/develop.rs#L1950), [`ui/dr-ui/src/develop.rs:1968`](../ui/dr-ui/src/develop.rs#L1968), [`ui/dr-ui/src/develop.rs:1982`](../ui/dr-ui/src/develop.rs#L1982), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2150`](../ui/dr-ui/src/develop.rs#L2150), [`ui/dr-ui/src/develop.rs:2248`](../ui/dr-ui/src/develop.rs#L2248), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3530`](../ui/dr-ui/src/develop.rs#L3530), [`ui/dr-ui/src/develop.rs:3596`](../ui/dr-ui/src/develop.rs#L3596), [`ui/dr-ui/src/develop.rs:3610`](../ui/dr-ui/src/develop.rs#L3610), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:4462`](../ui/dr-ui/src/develop.rs#L4462), [`ui/dr-ui/src/develop.rs:4516`](../ui/dr-ui/src/develop.rs#L4516), [`ui/dr-ui/src/develop.rs:4560`](../ui/dr-ui/src/develop.rs#L4560), [`ui/dr-ui/src/develop.rs:4610`](../ui/dr-ui/src/develop.rs#L4610), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1607`](../ui/dr-ui/src/lib.rs#L1607), [`ui/dr-ui/src/lib.rs:2409`](../ui/dr-ui/src/lib.rs#L2409), [`ui/dr-ui/src/lib.rs:2605`](../ui/dr-ui/src/lib.rs#L2605), [`ui/dr-ui/src/lib.rs:2777`](../ui/dr-ui/src/lib.rs#L2777), [`ui/dr-ui/src/lib.rs:2841`](../ui/dr-ui/src/lib.rs#L2841), [`ui/dr-ui/src/lib.rs:2895`](../ui/dr-ui/src/lib.rs#L2895), [`ui/dr-ui/src/lib.rs:354`](../ui/dr-ui/src/lib.rs#L354), [`ui/dr-ui/src/lib.rs:397`](../ui/dr-ui/src/lib.rs#L397), [`ui/dr-ui/src/lib.rs:420`](../ui/dr-ui/src/lib.rs#L420), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`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:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:620`](../ui/dr-ui/ui/adjust.slint#L620), [`ui/dr-ui/ui/app.slint:149`](../ui/dr-ui/ui/app.slint#L149), [`ui/dr-ui/ui/app.slint:195`](../ui/dr-ui/ui/app.slint#L195), [`ui/dr-ui/ui/app.slint:1977`](../ui/dr-ui/ui/app.slint#L1977), [`ui/dr-ui/ui/app.slint:928`](../ui/dr-ui/ui/app.slint#L928), [`ui/dr-ui/ui/masks.slint:516`](../ui/dr-ui/ui/masks.slint#L516) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1369`](../ui/dr-ui/src/develop.rs#L1369), [`ui/dr-ui/src/lib.rs:703`](../ui/dr-ui/src/lib.rs#L703), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`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:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`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:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`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:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1472`](../ui/dr-ui/src/develop.rs#L1472), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1950`](../ui/dr-ui/src/develop.rs#L1950), [`ui/dr-ui/src/develop.rs:1968`](../ui/dr-ui/src/develop.rs#L1968), [`ui/dr-ui/src/develop.rs:1982`](../ui/dr-ui/src/develop.rs#L1982), [`ui/dr-ui/src/develop.rs:2004`](../ui/dr-ui/src/develop.rs#L2004), [`ui/dr-ui/src/develop.rs:2150`](../ui/dr-ui/src/develop.rs#L2150), [`ui/dr-ui/src/develop.rs:2248`](../ui/dr-ui/src/develop.rs#L2248), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3530`](../ui/dr-ui/src/develop.rs#L3530), [`ui/dr-ui/src/develop.rs:3596`](../ui/dr-ui/src/develop.rs#L3596), [`ui/dr-ui/src/develop.rs:3610`](../ui/dr-ui/src/develop.rs#L3610), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:4462`](../ui/dr-ui/src/develop.rs#L4462), [`ui/dr-ui/src/develop.rs:4516`](../ui/dr-ui/src/develop.rs#L4516), [`ui/dr-ui/src/develop.rs:4560`](../ui/dr-ui/src/develop.rs#L4560), [`ui/dr-ui/src/develop.rs:4610`](../ui/dr-ui/src/develop.rs#L4610), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:766`](../ui/dr-ui/src/develop.rs#L766), [`ui/dr-ui/src/develop.rs:813`](../ui/dr-ui/src/develop.rs#L813), [`ui/dr-ui/src/develop.rs:842`](../ui/dr-ui/src/develop.rs#L842), [`ui/dr-ui/src/lib.rs:1608`](../ui/dr-ui/src/lib.rs#L1608), [`ui/dr-ui/src/lib.rs:2410`](../ui/dr-ui/src/lib.rs#L2410), [`ui/dr-ui/src/lib.rs:2606`](../ui/dr-ui/src/lib.rs#L2606), [`ui/dr-ui/src/lib.rs:2778`](../ui/dr-ui/src/lib.rs#L2778), [`ui/dr-ui/src/lib.rs:2842`](../ui/dr-ui/src/lib.rs#L2842), [`ui/dr-ui/src/lib.rs:2896`](../ui/dr-ui/src/lib.rs#L2896), [`ui/dr-ui/src/lib.rs:355`](../ui/dr-ui/src/lib.rs#L355), [`ui/dr-ui/src/lib.rs:398`](../ui/dr-ui/src/lib.rs#L398), [`ui/dr-ui/src/lib.rs:421`](../ui/dr-ui/src/lib.rs#L421), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`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:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:547`](../ui/dr-ui/ui/adjust.slint#L547), [`ui/dr-ui/ui/adjust.slint:666`](../ui/dr-ui/ui/adjust.slint#L666), [`ui/dr-ui/ui/app.slint:150`](../ui/dr-ui/ui/app.slint#L150), [`ui/dr-ui/ui/app.slint:196`](../ui/dr-ui/ui/app.slint#L196), [`ui/dr-ui/ui/app.slint:2009`](../ui/dr-ui/ui/app.slint#L2009), [`ui/dr-ui/ui/app.slint:951`](../ui/dr-ui/ui/app.slint#L951), [`ui/dr-ui/ui/masks.slint:516`](../ui/dr-ui/ui/masks.slint#L516) | +| FR-DEV-3a | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1369`](../ui/dr-ui/src/develop.rs#L1369), [`ui/dr-ui/src/lib.rs:704`](../ui/dr-ui/src/lib.rs#L704), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:5189`](../ui/dr-ui/src/develop.rs#L5189) | +| FR-DEV-3c | [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:5189`](../ui/dr-ui/src/develop.rs#L5189) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1079`](../core/dr-gpu/src/adjust.rs#L1079), [`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/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:617`](../core/dr-pipeline/src/graph.rs#L617), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1958`](../core/dr-pipeline/src/sidecar.rs#L1958), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3244`](../ui/dr-ui/src/develop.rs#L3244), [`ui/dr-ui/src/develop.rs:3261`](../ui/dr-ui/src/develop.rs#L3261), [`ui/dr-ui/src/develop.rs:3273`](../ui/dr-ui/src/develop.rs#L3273), [`ui/dr-ui/src/develop.rs:3311`](../ui/dr-ui/src/develop.rs#L3311), [`ui/dr-ui/src/develop.rs:3320`](../ui/dr-ui/src/develop.rs#L3320), [`ui/dr-ui/src/develop.rs:3423`](../ui/dr-ui/src/develop.rs#L3423), [`ui/dr-ui/src/develop.rs:3839`](../ui/dr-ui/src/develop.rs#L3839), [`ui/dr-ui/src/develop.rs:3854`](../ui/dr-ui/src/develop.rs#L3854), [`ui/dr-ui/src/lib.rs:2383`](../ui/dr-ui/src/lib.rs#L2383), [`ui/dr-ui/src/lib.rs:636`](../ui/dr-ui/src/lib.rs#L636), [`ui/dr-ui/src/lib.rs:694`](../ui/dr-ui/src/lib.rs#L694), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:395`](../ui/dr-ui/src/presets.rs#L395), [`ui/dr-ui/ui/adjust.slint:1026`](../ui/dr-ui/ui/adjust.slint#L1026), [`ui/dr-ui/ui/adjust.slint:950`](../ui/dr-ui/ui/adjust.slint#L950), [`ui/dr-ui/ui/app.slint:2545`](../ui/dr-ui/ui/app.slint#L2545), [`ui/dr-ui/ui/app.slint:656`](../ui/dr-ui/ui/app.slint#L656) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1983`](../core/dr-pipeline/src/sidecar.rs#L1983), [`core/dr-pipeline/src/sidecar.rs:2059`](../core/dr-pipeline/src/sidecar.rs#L2059), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3244`](../ui/dr-ui/src/develop.rs#L3244), [`ui/dr-ui/src/develop.rs:3261`](../ui/dr-ui/src/develop.rs#L3261), [`ui/dr-ui/src/develop.rs:3273`](../ui/dr-ui/src/develop.rs#L3273), [`ui/dr-ui/src/develop.rs:3311`](../ui/dr-ui/src/develop.rs#L3311), [`ui/dr-ui/src/develop.rs:3320`](../ui/dr-ui/src/develop.rs#L3320), [`ui/dr-ui/src/develop.rs:3423`](../ui/dr-ui/src/develop.rs#L3423), [`ui/dr-ui/src/develop.rs:3839`](../ui/dr-ui/src/develop.rs#L3839), [`ui/dr-ui/src/develop.rs:3854`](../ui/dr-ui/src/develop.rs#L3854), [`ui/dr-ui/src/lib.rs:2384`](../ui/dr-ui/src/lib.rs#L2384), [`ui/dr-ui/src/lib.rs:637`](../ui/dr-ui/src/lib.rs#L637), [`ui/dr-ui/src/lib.rs:695`](../ui/dr-ui/src/lib.rs#L695), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:395`](../ui/dr-ui/src/presets.rs#L395), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:996`](../ui/dr-ui/ui/adjust.slint#L996), [`ui/dr-ui/ui/app.slint:2577`](../ui/dr-ui/ui/app.slint#L2577), [`ui/dr-ui/ui/app.slint:679`](../ui/dr-ui/ui/app.slint#L679) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:139`](../ui/dr-ui/src/develop.rs#L139), [`ui/dr-ui/src/develop.rs:2167`](../ui/dr-ui/src/develop.rs#L2167), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:351`](../core/dr-gpu/src/lib.rs#L351), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`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), [`ui/dr-ui/src/develop.rs:3273`](../ui/dr-ui/src/develop.rs#L3273), [`ui/dr-ui/src/develop.rs:3854`](../ui/dr-ui/src/develop.rs#L3854), [`ui/dr-ui/src/develop.rs:3884`](../ui/dr-ui/src/develop.rs#L3884), [`ui/dr-ui/src/develop.rs:3897`](../ui/dr-ui/src/develop.rs#L3897), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3925`](../ui/dr-ui/src/develop.rs#L3925), [`ui/dr-ui/src/develop.rs:3957`](../ui/dr-ui/src/develop.rs#L3957), [`ui/dr-ui/src/develop.rs:3961`](../ui/dr-ui/src/develop.rs#L3961), [`ui/dr-ui/src/develop.rs:3980`](../ui/dr-ui/src/develop.rs#L3980), [`ui/dr-ui/src/develop.rs:3996`](../ui/dr-ui/src/develop.rs#L3996), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1542`](../ui/dr-ui/src/lib.rs#L1542), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:1593`](../ui/dr-ui/src/lib.rs#L1593), [`ui/dr-ui/src/lib.rs:2579`](../ui/dr-ui/src/lib.rs#L2579), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3796`](../ui/dr-ui/src/develop.rs#L3796), [`ui/dr-ui/src/develop.rs:3817`](../ui/dr-ui/src/develop.rs#L3817), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/lib.rs:2194`](../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/lib.rs:2223`](../ui/dr-ui/src/lib.rs#L2223), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2696`](../ui/dr-ui/src/library_ui.rs#L2696), [`ui/dr-ui/src/library_ui.rs:3096`](../ui/dr-ui/src/library_ui.rs#L3096), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:657`](../ui/dr-ui/src/presets.rs#L657), [`ui/dr-ui/src/presets.rs:691`](../ui/dr-ui/src/presets.rs#L691), [`ui/dr-ui/ui/adjust.slint:694`](../ui/dr-ui/ui/adjust.slint#L694), [`ui/dr-ui/ui/adjust.slint:721`](../ui/dr-ui/ui/adjust.slint#L721), [`ui/dr-ui/ui/adjust.slint:755`](../ui/dr-ui/ui/adjust.slint#L755), [`ui/dr-ui/ui/app.slint:1531`](../ui/dr-ui/ui/app.slint#L1531), [`ui/dr-ui/ui/app.slint:2634`](../ui/dr-ui/ui/app.slint#L2634), [`ui/dr-ui/ui/app.slint:728`](../ui/dr-ui/ui/app.slint#L728), [`ui/dr-ui/ui/app.slint:739`](../ui/dr-ui/ui/app.slint#L739), [`ui/dr-ui/ui/library.slint:1406`](../ui/dr-ui/ui/library.slint#L1406), [`ui/dr-ui/ui/library.slint:1412`](../ui/dr-ui/ui/library.slint#L1412), [`ui/dr-ui/ui/library.slint:3832`](../ui/dr-ui/ui/library.slint#L3832), [`ui/dr-ui/ui/library.slint:3847`](../ui/dr-ui/ui/library.slint#L3847), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:557`](../ui/dr-ui/ui/settings.slint#L557) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3925`](../ui/dr-ui/src/develop.rs#L3925), [`ui/dr-ui/src/develop.rs:3957`](../ui/dr-ui/src/develop.rs#L3957), [`ui/dr-ui/src/lib.rs:1593`](../ui/dr-ui/src/lib.rs#L1593), [`ui/dr-ui/src/lib.rs:2579`](../ui/dr-ui/src/lib.rs#L2579), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2319`](../ui/dr-ui/src/develop.rs#L2319), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2422`](../ui/dr-ui/src/develop.rs#L2422), [`ui/dr-ui/src/develop.rs:2505`](../ui/dr-ui/src/develop.rs#L2505), [`ui/dr-ui/src/develop.rs:2519`](../ui/dr-ui/src/develop.rs#L2519), [`ui/dr-ui/src/develop.rs:796`](../ui/dr-ui/src/develop.rs#L796), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1614`](../ui/dr-ui/src/lib.rs#L1614), [`ui/dr-ui/src/lib.rs:2697`](../ui/dr-ui/src/lib.rs#L2697), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:786`](../ui/dr-ui/ui/adjust.slint#L786), [`ui/dr-ui/ui/app.slint:139`](../ui/dr-ui/ui/app.slint#L139), [`ui/dr-ui/ui/app.slint:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/app.slint:2055`](../ui/dr-ui/ui/app.slint#L2055), [`ui/dr-ui/ui/app.slint:2451`](../ui/dr-ui/ui/app.slint#L2451), [`ui/dr-ui/ui/icons.slint:338`](../ui/dr-ui/ui/icons.slint#L338), [`ui/dr-ui/ui/spots.slint:172`](../ui/dr-ui/ui/spots.slint#L172), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2126`](../core/dr-gpu/src/adjust.rs#L2126), [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:2288`](../core/dr-gpu/src/adjust.rs#L2288), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:548`](../core/dr-pipeline/src/graph.rs#L548), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`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:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2843`](../ui/dr-ui/src/develop.rs#L2843), [`ui/dr-ui/src/develop.rs:4251`](../ui/dr-ui/src/develop.rs#L4251), [`ui/dr-ui/src/develop.rs:4734`](../ui/dr-ui/src/develop.rs#L4734), [`ui/dr-ui/src/develop.rs:4768`](../ui/dr-ui/src/develop.rs#L4768), [`ui/dr-ui/src/lib.rs:844`](../ui/dr-ui/src/lib.rs#L844), [`ui/dr-ui/src/lib.rs:903`](../ui/dr-ui/src/lib.rs#L903), [`ui/dr-ui/src/lib.rs:90`](../ui/dr-ui/src/lib.rs#L90) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`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), [`ui/dr-ui/src/develop.rs:3273`](../ui/dr-ui/src/develop.rs#L3273), [`ui/dr-ui/src/develop.rs:3854`](../ui/dr-ui/src/develop.rs#L3854), [`ui/dr-ui/src/develop.rs:3884`](../ui/dr-ui/src/develop.rs#L3884), [`ui/dr-ui/src/develop.rs:3897`](../ui/dr-ui/src/develop.rs#L3897), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3925`](../ui/dr-ui/src/develop.rs#L3925), [`ui/dr-ui/src/develop.rs:3957`](../ui/dr-ui/src/develop.rs#L3957), [`ui/dr-ui/src/develop.rs:3961`](../ui/dr-ui/src/develop.rs#L3961), [`ui/dr-ui/src/develop.rs:3980`](../ui/dr-ui/src/develop.rs#L3980), [`ui/dr-ui/src/develop.rs:3996`](../ui/dr-ui/src/develop.rs#L3996), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1543`](../ui/dr-ui/src/lib.rs#L1543), [`ui/dr-ui/src/lib.rs:1586`](../ui/dr-ui/src/lib.rs#L1586), [`ui/dr-ui/src/lib.rs:1594`](../ui/dr-ui/src/lib.rs#L1594), [`ui/dr-ui/src/lib.rs:2580`](../ui/dr-ui/src/lib.rs#L2580), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:381`](../core/dr-pipeline/src/preset.rs#L381), [`core/dr-pipeline/src/preset.rs:414`](../core/dr-pipeline/src/preset.rs#L414), [`core/dr-pipeline/src/starter.rs:1`](../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3796`](../ui/dr-ui/src/develop.rs#L3796), [`ui/dr-ui/src/develop.rs:3817`](../ui/dr-ui/src/develop.rs#L3817), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:2195`](../ui/dr-ui/src/lib.rs#L2195), [`ui/dr-ui/src/lib.rs:2224`](../ui/dr-ui/src/lib.rs#L2224), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:3130`](../ui/dr-ui/src/library_ui.rs#L3130), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:657`](../ui/dr-ui/src/presets.rs#L657), [`ui/dr-ui/src/presets.rs:691`](../ui/dr-ui/src/presets.rs#L691), [`ui/dr-ui/ui/adjust.slint:740`](../ui/dr-ui/ui/adjust.slint#L740), [`ui/dr-ui/ui/adjust.slint:767`](../ui/dr-ui/ui/adjust.slint#L767), [`ui/dr-ui/ui/adjust.slint:801`](../ui/dr-ui/ui/adjust.slint#L801), [`ui/dr-ui/ui/app.slint:1562`](../ui/dr-ui/ui/app.slint#L1562), [`ui/dr-ui/ui/app.slint:2666`](../ui/dr-ui/ui/app.slint#L2666), [`ui/dr-ui/ui/app.slint:751`](../ui/dr-ui/ui/app.slint#L751), [`ui/dr-ui/ui/app.slint:762`](../ui/dr-ui/ui/app.slint#L762), [`ui/dr-ui/ui/library.slint:1456`](../ui/dr-ui/ui/library.slint#L1456), [`ui/dr-ui/ui/library.slint:1462`](../ui/dr-ui/ui/library.slint#L1462), [`ui/dr-ui/ui/library.slint:3964`](../ui/dr-ui/ui/library.slint#L3964), [`ui/dr-ui/ui/library.slint:3979`](../ui/dr-ui/ui/library.slint#L3979), [`ui/dr-ui/ui/presets.slint:105`](../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:101`](../ui/dr-ui/ui/settings.slint#L101), [`ui/dr-ui/ui/settings.slint:557`](../ui/dr-ui/ui/settings.slint#L557) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3925`](../ui/dr-ui/src/develop.rs#L3925), [`ui/dr-ui/src/develop.rs:3957`](../ui/dr-ui/src/develop.rs#L3957), [`ui/dr-ui/src/lib.rs:1594`](../ui/dr-ui/src/lib.rs#L1594), [`ui/dr-ui/src/lib.rs:2580`](../ui/dr-ui/src/lib.rs#L2580), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2319`](../ui/dr-ui/src/develop.rs#L2319), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2422`](../ui/dr-ui/src/develop.rs#L2422), [`ui/dr-ui/src/develop.rs:2505`](../ui/dr-ui/src/develop.rs#L2505), [`ui/dr-ui/src/develop.rs:2519`](../ui/dr-ui/src/develop.rs#L2519), [`ui/dr-ui/src/develop.rs:796`](../ui/dr-ui/src/develop.rs#L796), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1615`](../ui/dr-ui/src/lib.rs#L1615), [`ui/dr-ui/src/lib.rs:2698`](../ui/dr-ui/src/lib.rs#L2698), [`ui/dr-ui/src/lib.rs:360`](../ui/dr-ui/src/lib.rs#L360), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:832`](../ui/dr-ui/ui/adjust.slint#L832), [`ui/dr-ui/ui/app.slint:140`](../ui/dr-ui/ui/app.slint#L140), [`ui/dr-ui/ui/app.slint:1912`](../ui/dr-ui/ui/app.slint#L1912), [`ui/dr-ui/ui/app.slint:2087`](../ui/dr-ui/ui/app.slint#L2087), [`ui/dr-ui/ui/app.slint:2483`](../ui/dr-ui/ui/app.slint#L2483), [`ui/dr-ui/ui/icons.slint:338`](../ui/dr-ui/ui/icons.slint#L338), [`ui/dr-ui/ui/spots.slint:172`](../ui/dr-ui/ui/spots.slint#L172), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2126`](../core/dr-gpu/src/adjust.rs#L2126), [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:2288`](../core/dr-gpu/src/adjust.rs#L2288), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:172`](../core/dr-gpu/src/lib.rs#L172), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-gpu/src/lib.rs:70`](../core/dr-gpu/src/lib.rs#L70), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:548`](../core/dr-pipeline/src/graph.rs#L548), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`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:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2843`](../ui/dr-ui/src/develop.rs#L2843), [`ui/dr-ui/src/develop.rs:4251`](../ui/dr-ui/src/develop.rs#L4251), [`ui/dr-ui/src/develop.rs:4734`](../ui/dr-ui/src/develop.rs#L4734), [`ui/dr-ui/src/develop.rs:4768`](../ui/dr-ui/src/develop.rs#L4768), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`ui/dr-ui/src/lib.rs:904`](../ui/dr-ui/src/lib.rs#L904), [`ui/dr-ui/src/lib.rs:91`](../ui/dr-ui/src/lib.rs#L91) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2873`](../ui/dr-ui/src/develop.rs#L2873), [`ui/dr-ui/src/develop.rs:6162`](../ui/dr-ui/src/develop.rs#L6162), [`ui/dr-ui/src/lib.rs:1570`](../ui/dr-ui/src/lib.rs#L1570), [`ui/dr-ui/src/lib.rs:3080`](../ui/dr-ui/src/lib.rs#L3080) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2922`](../ui/dr-ui/src/develop.rs#L2922), [`ui/dr-ui/src/develop.rs:5836`](../ui/dr-ui/src/develop.rs#L5836), [`ui/dr-ui/src/develop.rs:5868`](../ui/dr-ui/src/develop.rs#L5868), [`ui/dr-ui/src/develop.rs:720`](../ui/dr-ui/src/develop.rs#L720), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1680`](../ui/dr-ui/src/lib.rs#L1680), [`ui/dr-ui/src/lib.rs:338`](../ui/dr-ui/src/lib.rs#L338), [`ui/dr-ui/ui/app.slint:73`](../ui/dr-ui/ui/app.slint#L73), [`ui/dr-ui/ui/histogram.slint:167`](../ui/dr-ui/ui/histogram.slint#L167), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | -| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2819`](../ui/dr-ui/src/develop.rs#L2819), [`ui/dr-ui/src/develop.rs:2873`](../ui/dr-ui/src/develop.rs#L2873), [`ui/dr-ui/src/develop.rs:6162`](../ui/dr-ui/src/develop.rs#L6162), [`ui/dr-ui/src/develop.rs:6208`](../ui/dr-ui/src/develop.rs#L6208), [`ui/dr-ui/src/develop.rs:6227`](../ui/dr-ui/src/develop.rs#L6227), [`ui/dr-ui/src/develop.rs:827`](../ui/dr-ui/src/develop.rs#L827), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1531`](../ui/dr-ui/src/lib.rs#L1531), [`ui/dr-ui/src/lib.rs:1570`](../ui/dr-ui/src/lib.rs#L1570), [`ui/dr-ui/src/lib.rs:2993`](../ui/dr-ui/src/lib.rs#L2993), [`ui/dr-ui/src/lib.rs:3080`](../ui/dr-ui/src/lib.rs#L3080), [`ui/dr-ui/ui/app.slint:1728`](../ui/dr-ui/ui/app.slint#L1728), [`ui/dr-ui/ui/app.slint:52`](../ui/dr-ui/ui/app.slint#L52), [`ui/dr-ui/ui/settings.slint:124`](../ui/dr-ui/ui/settings.slint#L124), [`ui/dr-ui/ui/settings.slint:794`](../ui/dr-ui/ui/settings.slint#L794) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2873`](../ui/dr-ui/src/develop.rs#L2873), [`ui/dr-ui/src/develop.rs:6162`](../ui/dr-ui/src/develop.rs#L6162), [`ui/dr-ui/src/lib.rs:1571`](../ui/dr-ui/src/lib.rs#L1571), [`ui/dr-ui/src/lib.rs:3081`](../ui/dr-ui/src/lib.rs#L3081) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2922`](../ui/dr-ui/src/develop.rs#L2922), [`ui/dr-ui/src/develop.rs:5836`](../ui/dr-ui/src/develop.rs#L5836), [`ui/dr-ui/src/develop.rs:5868`](../ui/dr-ui/src/develop.rs#L5868), [`ui/dr-ui/src/develop.rs:720`](../ui/dr-ui/src/develop.rs#L720), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1681`](../ui/dr-ui/src/lib.rs#L1681), [`ui/dr-ui/src/lib.rs:339`](../ui/dr-ui/src/lib.rs#L339), [`ui/dr-ui/ui/app.slint:74`](../ui/dr-ui/ui/app.slint#L74), [`ui/dr-ui/ui/histogram.slint:168`](../ui/dr-ui/ui/histogram.slint#L168), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2819`](../ui/dr-ui/src/develop.rs#L2819), [`ui/dr-ui/src/develop.rs:2873`](../ui/dr-ui/src/develop.rs#L2873), [`ui/dr-ui/src/develop.rs:6162`](../ui/dr-ui/src/develop.rs#L6162), [`ui/dr-ui/src/develop.rs:6208`](../ui/dr-ui/src/develop.rs#L6208), [`ui/dr-ui/src/develop.rs:6227`](../ui/dr-ui/src/develop.rs#L6227), [`ui/dr-ui/src/develop.rs:827`](../ui/dr-ui/src/develop.rs#L827), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1532`](../ui/dr-ui/src/lib.rs#L1532), [`ui/dr-ui/src/lib.rs:1571`](../ui/dr-ui/src/lib.rs#L1571), [`ui/dr-ui/src/lib.rs:2994`](../ui/dr-ui/src/lib.rs#L2994), [`ui/dr-ui/src/lib.rs:3081`](../ui/dr-ui/src/lib.rs#L3081), [`ui/dr-ui/ui/app.slint:1760`](../ui/dr-ui/ui/app.slint#L1760), [`ui/dr-ui/ui/app.slint:53`](../ui/dr-ui/ui/app.slint#L53), [`ui/dr-ui/ui/settings.slint:124`](../ui/dr-ui/ui/settings.slint#L124), [`ui/dr-ui/ui/settings.slint:794`](../ui/dr-ui/ui/settings.slint#L794) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2436`](../core/dr-gpu/src/adjust.rs#L2436), [`core/dr-pipeline/src/graph.rs:538`](../core/dr-pipeline/src/graph.rs#L538), [`core/dr-pipeline/src/graph.rs:592`](../core/dr-pipeline/src/graph.rs#L592), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:713`](../core/dr-types/src/settings.rs#L713), [`ui/dr-ui/src/develop.rs:6227`](../ui/dr-ui/src/develop.rs#L6227), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:758`](../core/dr-types/src/settings.rs#L758), [`core/dr-types/src/settings.rs:766`](../core/dr-types/src/settings.rs#L766), [`core/dr-types/src/settings.rs:782`](../core/dr-types/src/settings.rs#L782), [`core/dr-types/src/settings.rs:851`](../core/dr-types/src/settings.rs#L851), [`core/dr-types/src/settings.rs:879`](../core/dr-types/src/settings.rs#L879), [`core/dr-types/src/settings.rs:890`](../core/dr-types/src/settings.rs#L890), [`core/dr-types/src/settings.rs:896`](../core/dr-types/src/settings.rs#L896), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:207`](../ui/dr-ui/src/settings_ui.rs#L207), [`ui/dr-ui/src/settings_ui.rs:220`](../ui/dr-ui/src/settings_ui.rs#L220), [`ui/dr-ui/src/settings_ui.rs:235`](../ui/dr-ui/src/settings_ui.rs#L235), [`ui/dr-ui/src/settings_ui.rs:565`](../ui/dr-ui/src/settings_ui.rs#L565), [`ui/dr-ui/ui/settings.slint:697`](../ui/dr-ui/ui/settings.slint#L697), [`ui/dr-ui/ui/settings.slint:707`](../ui/dr-ui/ui/settings.slint#L707) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:449`](../ui/dr-ui/src/lib.rs#L449), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:679`](../ui/dr-ui/src/settings_ui.rs#L679) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:228`](../ui/dr-ui/src/lib.rs#L228), [`ui/dr-ui/src/lib.rs:2325`](../ui/dr-ui/src/lib.rs#L2325), [`ui/dr-ui/src/lib.rs:449`](../ui/dr-ui/src/lib.rs#L449), [`ui/dr-ui/src/lib.rs:484`](../ui/dr-ui/src/lib.rs#L484), [`ui/dr-ui/src/lib.rs:511`](../ui/dr-ui/src/lib.rs#L511), [`ui/dr-ui/src/library_ui.rs:3479`](../ui/dr-ui/src/library_ui.rs#L3479), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/library_ui.rs:6658`](../ui/dr-ui/src/library_ui.rs#L6658), [`ui/dr-ui/src/library_ui.rs:6735`](../ui/dr-ui/src/library_ui.rs#L6735), [`ui/dr-ui/src/library_ui.rs:6747`](../ui/dr-ui/src/library_ui.rs#L6747), [`ui/dr-ui/src/library_ui.rs:685`](../ui/dr-ui/src/library_ui.rs#L685), [`ui/dr-ui/src/library_ui.rs:742`](../ui/dr-ui/src/library_ui.rs#L742), [`ui/dr-ui/ui/app.slint:1064`](../ui/dr-ui/ui/app.slint#L1064), [`ui/dr-ui/ui/app.slint:1541`](../ui/dr-ui/ui/app.slint#L1541), [`ui/dr-ui/ui/library.slint:1415`](../ui/dr-ui/ui/library.slint#L1415), [`ui/dr-ui/ui/library.slint:3872`](../ui/dr-ui/ui/library.slint#L3872) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:450`](../ui/dr-ui/src/lib.rs#L450), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:679`](../ui/dr-ui/src/settings_ui.rs#L679) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:229`](../ui/dr-ui/src/lib.rs#L229), [`ui/dr-ui/src/lib.rs:2326`](../ui/dr-ui/src/lib.rs#L2326), [`ui/dr-ui/src/lib.rs:450`](../ui/dr-ui/src/lib.rs#L450), [`ui/dr-ui/src/lib.rs:485`](../ui/dr-ui/src/lib.rs#L485), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/library_ui.rs:3513`](../ui/dr-ui/src/library_ui.rs#L3513), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/library_ui.rs:6699`](../ui/dr-ui/src/library_ui.rs#L6699), [`ui/dr-ui/src/library_ui.rs:6776`](../ui/dr-ui/src/library_ui.rs#L6776), [`ui/dr-ui/src/library_ui.rs:6788`](../ui/dr-ui/src/library_ui.rs#L6788), [`ui/dr-ui/src/library_ui.rs:685`](../ui/dr-ui/src/library_ui.rs#L685), [`ui/dr-ui/src/library_ui.rs:742`](../ui/dr-ui/src/library_ui.rs#L742), [`ui/dr-ui/ui/app.slint:1087`](../ui/dr-ui/ui/app.slint#L1087), [`ui/dr-ui/ui/app.slint:1572`](../ui/dr-ui/ui/app.slint#L1572), [`ui/dr-ui/ui/library.slint:1465`](../ui/dr-ui/ui/library.slint#L1465), [`ui/dr-ui/ui/library.slint:4004`](../ui/dr-ui/ui/library.slint#L4004) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:653`](../core/dr-types/src/lib.rs#L653), [`core/dr-types/src/settings.rs:426`](../core/dr-types/src/settings.rs#L426), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/src/develop.rs:3188`](../ui/dr-ui/src/develop.rs#L3188), [`ui/dr-ui/src/lib.rs:449`](../ui/dr-ui/src/lib.rs#L449) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/src/develop.rs:3188`](../ui/dr-ui/src/develop.rs#L3188), [`ui/dr-ui/src/lib.rs:450`](../ui/dr-ui/src/lib.rs#L450) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | -| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:511`](../ui/dr-ui/src/lib.rs#L511), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:3479`](../ui/dr-ui/src/library_ui.rs#L3479), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1702`](../ui/dr-ui/src/library_ui.rs#L1702), [`ui/dr-ui/src/library_ui.rs:3513`](../ui/dr-ui/src/library_ui.rs#L3513), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:40`](../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/lib.rs:914`](../core/dr-sync-nextcloud/src/lib.rs#L914), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`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), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3731`](../ui/dr-ui/src/library_ui.rs#L3731), [`ui/dr-ui/src/library_ui.rs:4749`](../ui/dr-ui/src/library_ui.rs#L4749), [`ui/dr-ui/ui/app.slint:387`](../ui/dr-ui/ui/app.slint#L387), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | +| FR-NC-3 | [`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), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3765`](../ui/dr-ui/src/library_ui.rs#L3765), [`ui/dr-ui/src/library_ui.rs:4785`](../ui/dr-ui/src/library_ui.rs#L4785), [`ui/dr-ui/ui/app.slint:403`](../ui/dr-ui/ui/app.slint#L403), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387), [`ui/dr-ui/ui/settings.slint:73`](../ui/dr-ui/ui/settings.slint#L73) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`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:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) | | 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:488`](../ui/dr-ui/src/import.rs#L488) | | 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/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1093`](../core/dr-catalog/src/schema.rs#L1093), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`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:3255`](../ui/dr-ui/src/collections_ui.rs#L3255), [`ui/dr-ui/src/collections_ui.rs:599`](../ui/dr-ui/src/collections_ui.rs#L599), [`ui/dr-ui/src/collections_ui.rs:667`](../ui/dr-ui/src/collections_ui.rs#L667), [`ui/dr-ui/src/lib.rs:2029`](../ui/dr-ui/src/lib.rs#L2029), [`ui/dr-ui/src/lib.rs:3143`](../ui/dr-ui/src/lib.rs#L3143), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1603`](../ui/dr-ui/src/library_ui.rs#L1603), [`ui/dr-ui/src/library_ui.rs:2171`](../ui/dr-ui/src/library_ui.rs#L2171), [`ui/dr-ui/src/library_ui.rs:289`](../ui/dr-ui/src/library_ui.rs#L289), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:320`](../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:329`](../ui/dr-ui/src/library_ui.rs#L329), [`ui/dr-ui/src/library_ui.rs:422`](../ui/dr-ui/src/library_ui.rs#L422), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:479`](../ui/dr-ui/src/library_ui.rs#L479), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:5531`](../ui/dr-ui/src/library_ui.rs#L5531), [`ui/dr-ui/src/library_ui.rs:5549`](../ui/dr-ui/src/library_ui.rs#L5549), [`ui/dr-ui/src/library_ui.rs:5561`](../ui/dr-ui/src/library_ui.rs#L5561), [`ui/dr-ui/src/library_ui.rs:573`](../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`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:1440`](../ui/dr-ui/ui/app.slint#L1440), [`ui/dr-ui/ui/app.slint:2624`](../ui/dr-ui/ui/app.slint#L2624), [`ui/dr-ui/ui/app.slint:511`](../ui/dr-ui/ui/app.slint#L511), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:360`](../ui/dr-ui/ui/collections.slint#L360), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:372`](../ui/dr-ui/ui/collections.slint#L372), [`ui/dr-ui/ui/collections.slint:395`](../ui/dr-ui/ui/collections.slint#L395), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:596`](../ui/dr-ui/ui/collections.slint#L596), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3255`](../ui/dr-ui/src/collections_ui.rs#L3255), [`ui/dr-ui/src/collections_ui.rs:599`](../ui/dr-ui/src/collections_ui.rs#L599), [`ui/dr-ui/src/collections_ui.rs:667`](../ui/dr-ui/src/collections_ui.rs#L667), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1685`](../ui/dr-ui/src/library.rs#L1685), [`ui/dr-ui/src/library.rs:1775`](../ui/dr-ui/src/library.rs#L1775), [`ui/dr-ui/src/library.rs:3254`](../ui/dr-ui/src/library.rs#L3254), [`ui/dr-ui/src/library.rs:3592`](../ui/dr-ui/src/library.rs#L3592), [`ui/dr-ui/src/library.rs:981`](../ui/dr-ui/src/library.rs#L981), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1393`](../ui/dr-ui/src/library_ui.rs#L1393), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1093`](../core/dr-catalog/src/schema.rs#L1093), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`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:3413`](../ui/dr-ui/src/collections_ui.rs#L3413), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/collections_ui.rs:704`](../ui/dr-ui/src/collections_ui.rs#L704), [`ui/dr-ui/src/lib.rs:2030`](../ui/dr-ui/src/lib.rs#L2030), [`ui/dr-ui/src/lib.rs:3144`](../ui/dr-ui/src/lib.rs#L3144), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library_ui.rs:1127`](../ui/dr-ui/src/library_ui.rs#L1127), [`ui/dr-ui/src/library_ui.rs:1200`](../ui/dr-ui/src/library_ui.rs#L1200), [`ui/dr-ui/src/library_ui.rs:1301`](../ui/dr-ui/src/library_ui.rs#L1301), [`ui/dr-ui/src/library_ui.rs:1356`](../ui/dr-ui/src/library_ui.rs#L1356), [`ui/dr-ui/src/library_ui.rs:1491`](../ui/dr-ui/src/library_ui.rs#L1491), [`ui/dr-ui/src/library_ui.rs:1609`](../ui/dr-ui/src/library_ui.rs#L1609), [`ui/dr-ui/src/library_ui.rs:2205`](../ui/dr-ui/src/library_ui.rs#L2205), [`ui/dr-ui/src/library_ui.rs:289`](../ui/dr-ui/src/library_ui.rs#L289), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:320`](../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:329`](../ui/dr-ui/src/library_ui.rs#L329), [`ui/dr-ui/src/library_ui.rs:422`](../ui/dr-ui/src/library_ui.rs#L422), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:479`](../ui/dr-ui/src/library_ui.rs#L479), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:5567`](../ui/dr-ui/src/library_ui.rs#L5567), [`ui/dr-ui/src/library_ui.rs:5585`](../ui/dr-ui/src/library_ui.rs#L5585), [`ui/dr-ui/src/library_ui.rs:5597`](../ui/dr-ui/src/library_ui.rs#L5597), [`ui/dr-ui/src/library_ui.rs:573`](../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`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:1471`](../ui/dr-ui/ui/app.slint#L1471), [`ui/dr-ui/ui/app.slint:2656`](../ui/dr-ui/ui/app.slint#L2656), [`ui/dr-ui/ui/app.slint:527`](../ui/dr-ui/ui/app.slint#L527), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:360`](../ui/dr-ui/ui/collections.slint#L360), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:372`](../ui/dr-ui/ui/collections.slint#L372), [`ui/dr-ui/ui/collections.slint:395`](../ui/dr-ui/ui/collections.slint#L395), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:596`](../ui/dr-ui/ui/collections.slint#L596), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1356`](../ui/dr-ui/src/library_ui.rs#L1356), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3413`](../ui/dr-ui/src/collections_ui.rs#L3413), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/collections_ui.rs:704`](../ui/dr-ui/src/collections_ui.rs#L704), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1685`](../ui/dr-ui/src/library.rs#L1685), [`ui/dr-ui/src/library.rs:1775`](../ui/dr-ui/src/library.rs#L1775), [`ui/dr-ui/src/library.rs:3254`](../ui/dr-ui/src/library.rs#L3254), [`ui/dr-ui/src/library.rs:3592`](../ui/dr-ui/src/library.rs#L3592), [`ui/dr-ui/src/library.rs:981`](../ui/dr-ui/src/library.rs#L981), [`ui/dr-ui/src/library_ui.rs:1127`](../ui/dr-ui/src/library_ui.rs#L1127), [`ui/dr-ui/src/library_ui.rs:1200`](../ui/dr-ui/src/library_ui.rs#L1200), [`ui/dr-ui/src/library_ui.rs:1399`](../ui/dr-ui/src/library_ui.rs#L1399), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/icons.slint:261`](../ui/dr-ui/ui/icons.slint#L261) | | FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:3731`](../ui/dr-ui/src/library_ui.rs#L3731), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387) | -| 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:206`](../core/dr-types/src/settings.rs#L206), [`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:1230`](../ui/dr-ui/src/lib.rs#L1230), [`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:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`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:1230`](../ui/dr-ui/src/lib.rs#L1230) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:1999`](../ui/dr-ui/src/lib.rs#L1999), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:451`](../core/dr-pipeline/src/sidecar.rs#L451), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:1031`](../ui/dr-ui/src/library.rs#L1031), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | -| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | -| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-sync/src/scan.rs:490`](../core/dr-sync/src/scan.rs#L490), [`core/dr-sync/src/scan.rs:519`](../core/dr-sync/src/scan.rs#L519), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1644`](../ui/dr-ui/src/library_ui.rs#L1644), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:966`](../ui/dr-ui/src/library_ui.rs#L966) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:3765`](../ui/dr-ui/src/library_ui.rs#L3765), [`ui/dr-ui/ui/settings.slint:387`](../ui/dr-ui/ui/settings.slint#L387) | +| 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:206`](../core/dr-types/src/settings.rs#L206), [`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:1231`](../ui/dr-ui/src/lib.rs#L1231), [`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:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`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:1231`](../ui/dr-ui/src/lib.rs#L1231) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:2000`](../ui/dr-ui/src/lib.rs#L2000), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:2059`](../core/dr-pipeline/src/sidecar.rs#L2059), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:451`](../core/dr-pipeline/src/sidecar.rs#L451), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:1031`](../ui/dr-ui/src/library.rs#L1031), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | +| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-sync/src/scan.rs:490`](../core/dr-sync/src/scan.rs#L490), [`core/dr-sync/src/scan.rs:519`](../core/dr-sync/src/scan.rs#L519), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library_ui.rs:1051`](../ui/dr-ui/src/library_ui.rs#L1051), [`ui/dr-ui/src/library_ui.rs:1650`](../ui/dr-ui/src/library_ui.rs#L1650), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:972`](../ui/dr-ui/src/library_ui.rs#L972) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | -| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:71`](../apps/darkroom-android/src/lib.rs#L71), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:1054`](../ui/dr-ui/src/lib.rs#L1054), [`ui/dr-ui/src/lib.rs:1483`](../ui/dr-ui/src/lib.rs#L1483), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | -| FR-PLAT-AND-6 | [`apps/darkroom-android/src/lib.rs:231`](../apps/darkroom-android/src/lib.rs#L231) | -| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:953`](../ui/dr-ui/src/lib.rs#L953), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:133`](../apps/darkroom-android/src/lib.rs#L133), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:1055`](../ui/dr-ui/src/lib.rs#L1055), [`ui/dr-ui/src/lib.rs:1484`](../ui/dr-ui/src/lib.rs#L1484), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-PLAT-AND-6 | [`apps/darkroom-android/src/lib.rs:293`](../apps/darkroom-android/src/lib.rs#L293) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:954`](../ui/dr-ui/src/lib.rs#L954), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:652`](../core/dr-pipeline/src/descriptor.rs#L652), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | | FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | +| FR-PLG-8 | [`core/dr-pipeline/src/sidecar.rs:1730`](../core/dr-pipeline/src/sidecar.rs#L1730), [`core/dr-pipeline/src/sidecar.rs:1763`](../core/dr-pipeline/src/sidecar.rs#L1763) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:130`](../core/dr-types/src/lib.rs#L130), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | -| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:228`](../ui/dr-ui/src/lib.rs#L228) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:229`](../ui/dr-ui/src/lib.rs#L229) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:105`](../ui/dr-ui/src/lib.rs#L105), [`ui/dr-ui/src/lib.rs:3225`](../ui/dr-ui/src/lib.rs#L3225), [`ui/dr-ui/src/lib.rs:98`](../ui/dr-ui/src/lib.rs#L98), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/app.slint:2168`](../ui/dr-ui/ui/app.slint#L2168), [`ui/dr-ui/ui/identity.slint:181`](../ui/dr-ui/ui/identity.slint#L181), [`ui/dr-ui/ui/library.slint:965`](../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/toolrail.slint:88`](../ui/dr-ui/ui/toolrail.slint#L88) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1008`](../ui/dr-ui/src/collections_ui.rs#L1008), [`ui/dr-ui/src/collections_ui.rs:1018`](../ui/dr-ui/src/collections_ui.rs#L1018), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:1501`](../ui/dr-ui/src/collections_ui.rs#L1501), [`ui/dr-ui/src/collections_ui.rs:1515`](../ui/dr-ui/src/collections_ui.rs#L1515), [`ui/dr-ui/src/collections_ui.rs:160`](../ui/dr-ui/src/collections_ui.rs#L160), [`ui/dr-ui/src/collections_ui.rs:1767`](../ui/dr-ui/src/collections_ui.rs#L1767), [`ui/dr-ui/src/collections_ui.rs:2818`](../ui/dr-ui/src/collections_ui.rs#L2818), [`ui/dr-ui/src/collections_ui.rs:486`](../ui/dr-ui/src/collections_ui.rs#L486), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:98`](../ui/dr-ui/src/lib.rs#L98), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:5416`](../ui/dr-ui/src/library_ui.rs#L5416), [`ui/dr-ui/src/library_ui.rs:5561`](../ui/dr-ui/src/library_ui.rs#L5561), [`ui/dr-ui/src/library_ui.rs:6766`](../ui/dr-ui/src/library_ui.rs#L6766), [`ui/dr-ui/ui/app.slint:541`](../ui/dr-ui/ui/app.slint#L541), [`ui/dr-ui/ui/app.slint:548`](../ui/dr-ui/ui/app.slint#L548), [`ui/dr-ui/ui/app.slint:59`](../ui/dr-ui/ui/app.slint#L59), [`ui/dr-ui/ui/app.slint:899`](../ui/dr-ui/ui/app.slint#L899), [`ui/dr-ui/ui/controls.slint:474`](../ui/dr-ui/ui/controls.slint#L474), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1167`](../ui/dr-ui/ui/library.slint#L1167), [`ui/dr-ui/ui/library.slint:1174`](../ui/dr-ui/ui/library.slint#L1174), [`ui/dr-ui/ui/library.slint:1237`](../ui/dr-ui/ui/library.slint#L1237), [`ui/dr-ui/ui/library.slint:2396`](../ui/dr-ui/ui/library.slint#L2396), [`ui/dr-ui/ui/library.slint:3910`](../ui/dr-ui/ui/library.slint#L3910), [`ui/dr-ui/ui/library.slint:854`](../ui/dr-ui/ui/library.slint#L854), [`ui/dr-ui/ui/library.slint:887`](../ui/dr-ui/ui/library.slint#L887), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:110`](../ui/dr-ui/ui/settings.slint#L110) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2248`](../ui/dr-ui/src/develop.rs#L2248), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/library_ui.rs:4525`](../ui/dr-ui/src/library_ui.rs#L4525), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1977`](../ui/dr-ui/ui/app.slint#L1977), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/masks.slint:516`](../ui/dr-ui/ui/masks.slint#L516) | -| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`tools/traceability/src/gestures.rs:366`](../tools/traceability/src/gestures.rs#L366), [`ui/dr-ui/src/collections_ui.rs:1008`](../ui/dr-ui/src/collections_ui.rs#L1008), [`ui/dr-ui/src/collections_ui.rs:1018`](../ui/dr-ui/src/collections_ui.rs#L1018), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:132`](../ui/dr-ui/src/collections_ui.rs#L132), [`ui/dr-ui/src/collections_ui.rs:1501`](../ui/dr-ui/src/collections_ui.rs#L1501), [`ui/dr-ui/src/collections_ui.rs:1515`](../ui/dr-ui/src/collections_ui.rs#L1515), [`ui/dr-ui/src/collections_ui.rs:160`](../ui/dr-ui/src/collections_ui.rs#L160), [`ui/dr-ui/src/collections_ui.rs:1631`](../ui/dr-ui/src/collections_ui.rs#L1631), [`ui/dr-ui/src/collections_ui.rs:1767`](../ui/dr-ui/src/collections_ui.rs#L1767), [`ui/dr-ui/src/collections_ui.rs:1794`](../ui/dr-ui/src/collections_ui.rs#L1794), [`ui/dr-ui/src/collections_ui.rs:1893`](../ui/dr-ui/src/collections_ui.rs#L1893), [`ui/dr-ui/src/collections_ui.rs:2818`](../ui/dr-ui/src/collections_ui.rs#L2818), [`ui/dr-ui/src/collections_ui.rs:486`](../ui/dr-ui/src/collections_ui.rs#L486), [`ui/dr-ui/src/collections_ui.rs:560`](../ui/dr-ui/src/collections_ui.rs#L560), [`ui/dr-ui/src/gesture_book.rs:3`](../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/library_ui.rs:4525`](../ui/dr-ui/src/library_ui.rs#L4525), [`ui/dr-ui/src/library_ui.rs:4537`](../ui/dr-ui/src/library_ui.rs#L4537), [`ui/dr-ui/src/library_ui.rs:4589`](../ui/dr-ui/src/library_ui.rs#L4589), [`ui/dr-ui/src/library_ui.rs:4692`](../ui/dr-ui/src/library_ui.rs#L4692), [`ui/dr-ui/src/library_ui.rs:4720`](../ui/dr-ui/src/library_ui.rs#L4720), [`ui/dr-ui/src/library_ui.rs:5549`](../ui/dr-ui/src/library_ui.rs#L5549), [`ui/dr-ui/src/library_ui.rs:5561`](../ui/dr-ui/src/library_ui.rs#L5561), [`ui/dr-ui/ui/app.slint:1817`](../ui/dr-ui/ui/app.slint#L1817), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/app.slint:548`](../ui/dr-ui/ui/app.slint#L548), [`ui/dr-ui/ui/app.slint:633`](../ui/dr-ui/ui/app.slint#L633), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1142`](../ui/dr-ui/ui/library.slint#L1142), [`ui/dr-ui/ui/library.slint:1167`](../ui/dr-ui/ui/library.slint#L1167), [`ui/dr-ui/ui/library.slint:1174`](../ui/dr-ui/ui/library.slint#L1174), [`ui/dr-ui/ui/library.slint:1230`](../ui/dr-ui/ui/library.slint#L1230), [`ui/dr-ui/ui/library.slint:1237`](../ui/dr-ui/ui/library.slint#L1237), [`ui/dr-ui/ui/library.slint:2396`](../ui/dr-ui/ui/library.slint#L2396), [`ui/dr-ui/ui/library.slint:3794`](../ui/dr-ui/ui/library.slint#L3794), [`ui/dr-ui/ui/library.slint:3910`](../ui/dr-ui/ui/library.slint#L3910), [`ui/dr-ui/ui/library.slint:854`](../ui/dr-ui/ui/library.slint#L854), [`ui/dr-ui/ui/library.slint:881`](../ui/dr-ui/ui/library.slint#L881), [`ui/dr-ui/ui/library.slint:887`](../ui/dr-ui/ui/library.slint#L887), [`ui/dr-ui/ui/library.slint:942`](../ui/dr-ui/ui/library.slint#L942) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2662`](../ui/dr-ui/src/lib.rs#L2662), [`ui/dr-ui/src/lib.rs:3264`](../ui/dr-ui/src/lib.rs#L3264), [`ui/dr-ui/src/lib.rs:3449`](../ui/dr-ui/src/lib.rs#L3449), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:120`](../ui/dr-ui/ui/app.slint#L120), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:106`](../ui/dr-ui/src/lib.rs#L106), [`ui/dr-ui/src/lib.rs:3226`](../ui/dr-ui/src/lib.rs#L3226), [`ui/dr-ui/src/lib.rs:99`](../ui/dr-ui/src/lib.rs#L99), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/app.slint:2200`](../ui/dr-ui/ui/app.slint#L2200), [`ui/dr-ui/ui/identity.slint:188`](../ui/dr-ui/ui/identity.slint#L188), [`ui/dr-ui/ui/library.slint:967`](../ui/dr-ui/ui/library.slint#L967), [`ui/dr-ui/ui/toolrail.slint:88`](../ui/dr-ui/ui/toolrail.slint#L88) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1045`](../ui/dr-ui/src/collections_ui.rs#L1045), [`ui/dr-ui/src/collections_ui.rs:1055`](../ui/dr-ui/src/collections_ui.rs#L1055), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1569`](../ui/dr-ui/src/collections_ui.rs#L1569), [`ui/dr-ui/src/collections_ui.rs:1821`](../ui/dr-ui/src/collections_ui.rs#L1821), [`ui/dr-ui/src/collections_ui.rs:2926`](../ui/dr-ui/src/collections_ui.rs#L2926), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:99`](../ui/dr-ui/src/lib.rs#L99), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:5452`](../ui/dr-ui/src/library_ui.rs#L5452), [`ui/dr-ui/src/library_ui.rs:5597`](../ui/dr-ui/src/library_ui.rs#L5597), [`ui/dr-ui/src/library_ui.rs:6807`](../ui/dr-ui/src/library_ui.rs#L6807), [`ui/dr-ui/ui/app.slint:557`](../ui/dr-ui/ui/app.slint#L557), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/app.slint:60`](../ui/dr-ui/ui/app.slint#L60), [`ui/dr-ui/ui/app.slint:922`](../ui/dr-ui/ui/app.slint#L922), [`ui/dr-ui/ui/controls.slint:593`](../ui/dr-ui/ui/controls.slint#L593), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1169`](../ui/dr-ui/ui/library.slint#L1169), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:1273`](../ui/dr-ui/ui/library.slint#L1273), [`ui/dr-ui/ui/library.slint:2446`](../ui/dr-ui/ui/library.slint#L2446), [`ui/dr-ui/ui/library.slint:4042`](../ui/dr-ui/ui/library.slint#L4042), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889), [`ui/dr-ui/ui/presets.slint:19`](../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:110`](../ui/dr-ui/ui/settings.slint#L110) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2248`](../ui/dr-ui/src/develop.rs#L2248), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/library_ui.rs:4559`](../ui/dr-ui/src/library_ui.rs#L4559), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2009`](../ui/dr-ui/ui/app.slint#L2009), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:747`](../ui/dr-ui/ui/collections.slint#L747), [`ui/dr-ui/ui/masks.slint:516`](../ui/dr-ui/ui/masks.slint#L516) | +| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1045`](../ui/dr-ui/src/collections_ui.rs#L1045), [`ui/dr-ui/src/collections_ui.rs:1055`](../ui/dr-ui/src/collections_ui.rs#L1055), [`ui/dr-ui/src/collections_ui.rs:116`](../ui/dr-ui/src/collections_ui.rs#L116), [`ui/dr-ui/src/collections_ui.rs:117`](../ui/dr-ui/src/collections_ui.rs#L117), [`ui/dr-ui/src/collections_ui.rs:126`](../ui/dr-ui/src/collections_ui.rs#L126), [`ui/dr-ui/src/collections_ui.rs:154`](../ui/dr-ui/src/collections_ui.rs#L154), [`ui/dr-ui/src/collections_ui.rs:1552`](../ui/dr-ui/src/collections_ui.rs#L1552), [`ui/dr-ui/src/collections_ui.rs:1569`](../ui/dr-ui/src/collections_ui.rs#L1569), [`ui/dr-ui/src/collections_ui.rs:1685`](../ui/dr-ui/src/collections_ui.rs#L1685), [`ui/dr-ui/src/collections_ui.rs:1821`](../ui/dr-ui/src/collections_ui.rs#L1821), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/collections_ui.rs:1964`](../ui/dr-ui/src/collections_ui.rs#L1964), [`ui/dr-ui/src/collections_ui.rs:2926`](../ui/dr-ui/src/collections_ui.rs#L2926), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:365`](../ui/dr-ui/src/collections_ui.rs#L365), [`ui/dr-ui/src/collections_ui.rs:569`](../ui/dr-ui/src/collections_ui.rs#L569), [`ui/dr-ui/src/collections_ui.rs:589`](../ui/dr-ui/src/collections_ui.rs#L589), [`ui/dr-ui/src/gesture_book.rs:3`](../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/library_ui.rs:4559`](../ui/dr-ui/src/library_ui.rs#L4559), [`ui/dr-ui/src/library_ui.rs:4571`](../ui/dr-ui/src/library_ui.rs#L4571), [`ui/dr-ui/src/library_ui.rs:4625`](../ui/dr-ui/src/library_ui.rs#L4625), [`ui/dr-ui/src/library_ui.rs:4728`](../ui/dr-ui/src/library_ui.rs#L4728), [`ui/dr-ui/src/library_ui.rs:4756`](../ui/dr-ui/src/library_ui.rs#L4756), [`ui/dr-ui/src/library_ui.rs:5585`](../ui/dr-ui/src/library_ui.rs#L5585), [`ui/dr-ui/src/library_ui.rs:5597`](../ui/dr-ui/src/library_ui.rs#L5597), [`ui/dr-ui/ui/app.slint:1849`](../ui/dr-ui/ui/app.slint#L1849), [`ui/dr-ui/ui/app.slint:533`](../ui/dr-ui/ui/app.slint#L533), [`ui/dr-ui/ui/app.slint:562`](../ui/dr-ui/ui/app.slint#L562), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/app.slint:656`](../ui/dr-ui/ui/app.slint#L656), [`ui/dr-ui/ui/gestures.slint:1`](../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1144`](../ui/dr-ui/ui/library.slint#L1144), [`ui/dr-ui/ui/library.slint:1169`](../ui/dr-ui/ui/library.slint#L1169), [`ui/dr-ui/ui/library.slint:1176`](../ui/dr-ui/ui/library.slint#L1176), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:1266`](../ui/dr-ui/ui/library.slint#L1266), [`ui/dr-ui/ui/library.slint:1273`](../ui/dr-ui/ui/library.slint#L1273), [`ui/dr-ui/ui/library.slint:2446`](../ui/dr-ui/ui/library.slint#L2446), [`ui/dr-ui/ui/library.slint:3926`](../ui/dr-ui/ui/library.slint#L3926), [`ui/dr-ui/ui/library.slint:4042`](../ui/dr-ui/ui/library.slint#L4042), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/library.slint:883`](../ui/dr-ui/ui/library.slint#L883), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889), [`ui/dr-ui/ui/library.slint:944`](../ui/dr-ui/ui/library.slint#L944) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2663`](../ui/dr-ui/src/lib.rs#L2663), [`ui/dr-ui/src/lib.rs:3265`](../ui/dr-ui/src/lib.rs#L3265), [`ui/dr-ui/src/lib.rs:3450`](../ui/dr-ui/src/lib.rs#L3450), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:121`](../ui/dr-ui/ui/app.slint#L121), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385) | +| NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1) | +| NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:652`](../ui/dr-ui/ui/library.slint#L652), [`ui/dr-ui/ui/library.slint:800`](../ui/dr-ui/ui/library.slint#L800), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2359`](../ui/dr-ui/src/lib.rs#L2359), [`ui/dr-ui/ui/app.slint:1075`](../ui/dr-ui/ui/app.slint#L1075), [`ui/dr-ui/ui/library.slint:3872`](../ui/dr-ui/ui/library.slint#L3872) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2360`](../ui/dr-ui/src/lib.rs#L2360), [`ui/dr-ui/ui/app.slint:1098`](../ui/dr-ui/ui/app.slint#L1098), [`ui/dr-ui/ui/library.slint:4004`](../ui/dr-ui/ui/library.slint#L4004) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) | -| NFR-OPS-1 | [`tools/traceability/src/gestures.rs:1`](../tools/traceability/src/gestures.rs#L1), [`tools/traceability/src/lib.rs:268`](../tools/traceability/src/lib.rs#L268) | +| NFR-OPS-1 | [`platform/dr-plat/src/diagnostics.rs:1`](../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/state.rs:1`](../platform/dr-plat/src/state.rs#L1) | +| NFR-OPS-2 | [`platform/dr-plat/src/crash.rs:1`](../platform/dr-plat/src/crash.rs#L1) | | NFR-OPS-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| 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:481`](../tools/traceability/src/lib.rs#L481) | +| 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/bench/src/catalog_open.rs:1`](../tools/bench/src/catalog_open.rs#L1) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| NFR-P14 | [`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/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:3046`](../ui/dr-ui/src/develop.rs#L3046), [`ui/dr-ui/src/lib.rs:1725`](../ui/dr-ui/src/lib.rs#L1725) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:4163`](../ui/dr-ui/src/library.rs#L4163), [`ui/dr-ui/src/library.rs:5097`](../ui/dr-ui/src/library.rs#L5097), [`ui/dr-ui/src/library_ui.rs:4198`](../ui/dr-ui/src/library_ui.rs#L4198), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P14 | [`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/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:3046`](../ui/dr-ui/src/develop.rs#L3046), [`ui/dr-ui/src/lib.rs:1726`](../ui/dr-ui/src/lib.rs#L1726) | +| NFR-P3 | [`tools/bench/src/thumbnails.rs:1`](../tools/bench/src/thumbnails.rs#L1) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:4163`](../ui/dr-ui/src/library.rs#L4163), [`ui/dr-ui/src/library.rs:5097`](../ui/dr-ui/src/library.rs#L5097), [`ui/dr-ui/src/library_ui.rs:4232`](../ui/dr-ui/src/library_ui.rs#L4232), [`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:942`](../ui/dr-ui/src/export.rs#L942), [`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:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`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), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`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) | | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:192`](../core/dr-gpu/src/lib.rs#L192), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101) | -| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | +| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | 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:255`](../core/dr-catalog/src/lib.rs#L255), [`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:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:90`](../ui/dr-ui/src/lib.rs#L90), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3151`](../ui/dr-ui/src/develop.rs#L3151), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:91`](../ui/dr-ui/src/lib.rs#L91), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-gpu/src/lib.rs:93`](../core/dr-gpu/src/lib.rs#L93), [`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:2964`](../ui/dr-ui/src/library.rs#L2964) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | -| NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | +| NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/crash.rs:1`](../platform/dr-plat/src/crash.rs#L1), [`platform/dr-plat/src/diagnostics.rs:1`](../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/diagnostics/redact.rs:1`](../platform/dr-plat/src/diagnostics/redact.rs#L1), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | | NFR-SEC-3 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.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:521`](../core/dr-catalog/src/schema.rs#L521), [`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:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| R1 | [`tools/traceability/src/lib.rs:497`](../tools/traceability/src/lib.rs#L497), [`tools/traceability/src/lib.rs:501`](../tools/traceability/src/lib.rs#L501) | | R3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-pipeline/src/lib.rs:1`](../core/dr-pipeline/src/lib.rs#L1) | | R4 | [`core/dr-gpu/src/lib.rs:351`](../core/dr-gpu/src/lib.rs#L351) | | R6 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1) | ## Not yet tagged -57 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +54 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements +- FR-CAT-13 - FR-CAT-14 - FR-CULL-6 - FR-CULL-7 @@ -169,6 +173,7 @@ _None._ - FR-DSP-2 - FR-DSP-4 - FR-NC-11 +- FR-PLAT-AND-1 - FR-PLAT-LIN-3 - FR-PLG-1 - FR-PLG-10 @@ -189,34 +194,29 @@ _None._ - FR-PLG-6 - FR-PLG-6a - FR-PLG-7 -- FR-PLG-8 - FR-PLG-9 - FR-RAW-2 - NFR-A11Y-1 -- NFR-A11Y-2 -- NFR-A11Y-3 - NFR-ARCH-1 - NFR-COMPAT-1 - NFR-COMPAT-2 -- NFR-OPS-2 - NFR-OPS-4 - NFR-P10 - NFR-P11 - NFR-P12 - NFR-P15 - NFR-P2 -- NFR-P3 - NFR-P4 - NFR-P6 - NFR-P7 - NFR-P8 - NFR-R3 - NFR-R4 -- NFR-R6 - NFR-RES-2 - NFR-RES-3 - NFR-SEC-4 - NFR-SEC-6 +- R1 - R2 - R5 diff --git a/platform/dr-plat/Cargo.toml b/platform/dr-plat/Cargo.toml index d2ac29c..17602da 100644 --- a/platform/dr-plat/Cargo.toml +++ b/platform/dr-plat/Cargo.toml @@ -8,7 +8,12 @@ license.workspace = true [dependencies] dr-types.workspace = true thiserror.workspace = true -log.workspace = true +# `std` is not one of `log`'s default features, and `diagnostics::install` +# needs `set_boxed_logger`, which is gated behind it. Stated here rather than +# left to feature unification with whichever dependant happens to pull +# `env_logger` in — that unification holds for `cargo build --workspace` and +# not for `cargo build -p dr-plat`. +log = { workspace = true, features = ["std"] } [target.'cfg(all(unix, not(target_os = "android")))'.dependencies] keyring.workspace = true diff --git a/platform/dr-plat/src/crash.rs b/platform/dr-plat/src/crash.rs new file mode 100644 index 0000000..4d26207 --- /dev/null +++ b/platform/dr-plat/src/crash.rs @@ -0,0 +1,622 @@ +//! TRACES: NFR-OPS-2 | NFR-SEC-2 +//! Local crash capture. +//! +//! # What this is, and the half it deliberately is not +//! +//! NFR-OPS-2 is two sentences: *local crash capture always; upload only on +//! explicit opt-in.* Only the first is built here, and the second is not +//! half-built either — there is no upload path, no endpoint, no queue and no +//! "send this later" flag. Opt-in upload needs a server to receive it and a +//! consent flow that states what leaves the device (NFR-SEC-4, and the same +//! preview-and-consent step NFR-OPS-1 requires of the diagnostics bundle); +//! neither exists, and a transport built ahead of the consent is exactly the +//! shape of thing that later gets switched on by default. +//! +//! So a crash record is a file on the user's own disk. Nothing reads it but a +//! person. +//! +//! # Why a panic is worth writing down at all +//! +//! NFR-ARCH-4 says no worker error may panic the process, and the application +//! is built that way — errors are typed and attached to the image or job they +//! belong to. A panic is therefore, by construction, a *bug*: an invariant +//! this codebase believed and got wrong. Before this, one of those was +//! invisible on desktop (stderr, discarded with the terminal) and one line of +//! `log::error!` on Android. What the user saw was a job that stopped, or a +//! channel that closed and a control that went dead, with nothing to report. +//! +//! # What goes in a record, and what may never +//! +//! The rule the content is chosen under is NFR-SEC-2 — credentials never reach +//! logs or plain files — extended to the thing this application is actually +//! about: **a user's library is private, and its shape is private too.** A +//! path is not a neutral technical detail here. `/home/anna/Photos/2019 +//! Divorce/` names something about a person, and a crash record is a file that +//! gets attached to a bug report by someone trying to be helpful. +//! +//! Hence [`redact`], which is applied to the panic message *and* to the +//! backtrace before either is written, and which is deliberately blunt: it +//! removes anything that looks like a path or a URL, keeping only the basename +//! of `.rs` files so a backtrace is still readable. Over-redaction costs +//! legibility; under-redaction costs a user something they cannot take back. +//! +//! NFR-SEC-5 — face data never enters a diagnostics bundle or crash report +//! "under any configuration" — is met structurally rather than by filtering: +//! this module reads no catalog, opens no image, and touches no account. A +//! record is assembled from the panic hook's own arguments and from +//! [`std::env::consts`], and there is no code path from here to an embedding, +//! a crop, or a cluster. The message length cap is the backstop for the +//! remaining case — a panic payload that some *other* module formatted a large +//! value into. +//! +//! # What this leaves cheaper for NFR-OPS-1 +//! +//! The rotating on-disk log is unbuilt, and it wants three things that are +//! here: [`state_dir`] (the XDG state directory, resolved once, overridable +//! for Android where XDG does not exist), [`redact`] (NFR-OPS-1's "automatic +//! redaction of credentials and tokens" is the same function), and `prune` +//! (size-capped rotation is this counting files instead of bytes). A log +//! belongs beside `crash/` on desktop, and the diagnostics +//! bundle then has one directory to collect. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// How many crash records are kept, newest first. +/// +/// Ten because the useful pattern in a crash record is usually a *repeat* — +/// the same panic three launches running is a far stronger report than one — +/// and because these are a few kilobytes each, so the cap exists to stop a +/// crash loop filling a disk rather than to save space. +pub const KEEP_RECORDS: usize = 10; + +/// The longest panic message written to a record. +/// +/// A backstop, not a redaction: [`redact`] handles what must not be written at +/// all. This bounds what a panic that formatted something enormous into its +/// message — a decoded buffer, a `Vec` of embeddings — can put on disk. +const MAX_MESSAGE: usize = 2000; + +/// Android's per-app directory, once the entry point has said what it is. +static STATE_DIR: OnceLock = OnceLock::new(); + +/// Declare the directory this application may keep state in. +/// +/// Only Android needs to call this, and it must call it before a crash rather +/// than before the hook is installed — [`install`] resolves the directory at +/// crash time precisely so that the hook can go in first, covering the startup +/// it would otherwise miss. Neither `XDG_STATE_HOME` nor `HOME` is set there, +/// and the fallback would resolve to a path the app cannot write. +/// +/// Later calls are ignored rather than racing, matching +/// `dr_sync::account::set_data_dir`, which the same entry point calls for the +/// same reason. +pub fn set_state_dir(dir: PathBuf) { + let _ = STATE_DIR.set(dir); +} + +/// Where this application keeps state that is neither configuration nor cache. +/// +/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom`. +/// State rather than cache because a crash record must survive the sweep that +/// a cache directory exists to permit, and rather than config because it is +/// not something the user edits. +pub fn state_dir() -> PathBuf { + if let Some(d) = STATE_DIR.get() { + return d.clone(); + } + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state") + }) + .join("darkroom") +} + +/// Where crash records are written. +pub fn crash_dir() -> PathBuf { + state_dir().join("crash") +} + +/// Install the panic hook. +/// +/// Call once, as early as the entry point can — before the window, before any +/// store, before anything that could itself panic. The directory is resolved +/// lazily inside the hook, so installing this before +/// [`set_state_dir`] is correct rather than merely tolerated. +/// +/// The previously installed hook still runs afterwards. On desktop that is the +/// standard library's, which prints the panic to stderr, and a developer +/// watching a terminal should not lose that because the application started +/// writing files. stderr is the one surface that sees the message unredacted, +/// which is a considered exception: it is ephemeral, local, and never attached +/// to a bug report. +pub fn install(app_version: &str) { + let version = app_version.to_string(); + let previous = std::panic::take_hook(); + + std::panic::set_hook(Box::new(move |info| { + // A panic inside a panic hook aborts the process, which would replace + // a diagnosable crash with an undiagnosable one. Everything below is + // written to be infallible, and this is the admission that "written to + // be" is not the same as "is". + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let record = compose(&version, info); + // Redacted, because this is a log file and NFR-SEC-2 governs it. + // The unredacted form goes to stderr below, through the hook this + // one chained onto. + log::error!("panic: {}", record.summary); + match write_record(&crash_dir(), &record) { + // The name, not the path: the log this line lands in is a + // sibling of the record, and printing the directory would put + // the user's home in a file they may hand to someone. + Ok(path) => log::error!( + "crash record written: {}", + path.file_name().unwrap_or_default().to_string_lossy() + ), + Err(e) => log::error!("could not write a crash record: {e}"), + } + })); + + previous(info); + })); +} + +/// The crash records on disk, newest first. +/// +/// For a future diagnostics bundle, and for a person looking for the file to +/// attach to a report. +pub fn records() -> Vec { + let Ok(entries) = std::fs::read_dir(crash_dir()) else { + return Vec::new(); + }; + let mut out: Vec<(i64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + Some((timestamp_of(&path)?, path)) + }) + .collect(); + out.sort_by_key(|(when, _)| std::cmp::Reverse(*when)); + out.into_iter().map(|(_, p)| p).collect() +} + +/// One crash, formatted. +struct Record { + /// The whole file. + body: String, + /// One redacted line, for the log. + summary: String, + when: i64, +} + +/// Build a record from what the panic hook was handed. +/// +/// Split from the writing so the *content* rules — what is included, what is +/// redacted, what is capped — are testable without a filesystem, and so a +/// future diagnostics bundle can reuse the same composition. +fn compose(version: &str, info: &std::panic::PanicHookInfo<'_>) -> Record { + let payload = info.payload(); + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(|s| s.as_str())) + // A panic can carry any `Any`, and `panic_any` is used by some + // libraries. There is nothing to print, and saying so is better than + // an empty field that reads like a bug in this code. + .unwrap_or("(panic payload was not a string)"); + + let message = redact(&truncate(message, MAX_MESSAGE)); + let at = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + // `location()` is a compile-time source path, not one of the user's, + // but it goes through the same redaction: a dependency built from a + // registry checkout carries the *builder's* home directory in it. + .map(|s| redact(&s)) + .unwrap_or_else(|| "unknown".to_string()); + let thread = std::thread::current() + .name() + .unwrap_or("unnamed") + .to_string(); + + let when = now(); + let summary = format!("{message} (at {at}, thread {thread})"); + + let body = format!( + "darkroom-crash 1\n\ + version: {version}\n\ + when: {when}\n\ + os: {}\n\ + arch: {}\n\ + thread: {thread}\n\ + at: {at}\n\ + message: {message}\n\ + backtrace:\n{}\n", + std::env::consts::OS, + std::env::consts::ARCH, + redact(&std::backtrace::Backtrace::force_capture().to_string()), + ); + + Record { + body, + summary, + when, + } +} + +/// Write one record into `dir`, then prune. +/// +/// Takes the directory rather than calling [`crash_dir`] so a test can drive +/// the real writing path without an environment variable and without a +/// `OnceLock` it cannot reset. +fn write_record(dir: &Path, record: &Record) -> std::io::Result { + std::fs::create_dir_all(dir)?; + // The pid distinguishes two processes crashing in the same second, which + // is not hypothetical: a background export and the app can be separate + // processes on desktop, and a crash loop retries fast. + let path = dir.join(format!("crash-{}-{}.txt", record.when, std::process::id())); + std::fs::write(&path, &record.body)?; + prune(dir, KEEP_RECORDS); + Ok(path) +} + +/// Delete all but the `keep` newest records in `dir`. +/// +/// Best-effort: failing to delete an old record is no reason to lose the new +/// one, which is already on disk. +fn prune(dir: &Path, keep: usize) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut found: Vec<(i64, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let path = e.path(); + Some((timestamp_of(&path)?, path)) + }) + .collect(); + found.sort_by_key(|(when, _)| std::cmp::Reverse(*when)); + for (_, path) in found.into_iter().skip(keep) { + let _ = std::fs::remove_file(path); + } +} + +/// Pull the timestamp back out of a record's filename. +/// +/// Doubles as the filter that keeps anything else in the directory — an +/// editor's backup file, a log — from being counted as a crash record or +/// pruned as one. +fn timestamp_of(path: &Path) -> Option { + let name = path.file_name()?.to_str()?; + let rest = name.strip_prefix("crash-")?.strip_suffix(".txt")?; + rest.split('-').next()?.parse().ok() +} + +/// Remove from `text` everything that would say something about the user. +/// +/// # The rule, and why it is this blunt one +/// +/// A token containing `/` is treated as a path or a URL and removed. Rust +/// source files are the exception and keep their basename, because a backtrace +/// with no filenames is close to useless and `library.rs:1270` says nothing +/// about anybody. A token that reads like a secret — a long opaque run, or the +/// value beside a word like `password` — is removed regardless of shape. +/// +/// Blunter than a list of known-sensitive shapes on purpose. The cost of +/// over-redaction is a diagnostic that is harder to read; the cost of +/// under-redaction is a directory listing of somebody's photographs in a file +/// they may attach to a public bug report. Those are not comparable, and this +/// is not the place to be clever about the boundary. +/// +/// Known limits, stated rather than hidden: a path with no `/` in it (a bare +/// filename) survives, and a person's name that some *other* module formatted +/// into a panic message survives. Both are bounded by the fact that nothing +/// here reads the catalog — see the module documentation — and the second is +/// why `MAX_MESSAGE` exists. +pub fn redact(text: &str) -> String { + let mut out: Vec = Vec::new(); + let mut redact_next = false; + + for token in text.split_inclusive(char::is_whitespace) { + // Whitespace is preserved exactly — a backtrace is read as a shape as + // much as as text — so the classification runs on the token without + // its trailing space and the space is put back. + let trailing: String = token + .chars() + .skip_while(|c| !c.is_whitespace()) + .collect::(); + let word = &token[..token.len() - trailing.len()]; + + if word.is_empty() { + out.push(token.to_string()); + continue; + } + + let replacement = if redact_next { + Some("".to_string()) + } else { + classify(word) + }; + redact_next = names_a_secret(word); + + out.push(match replacement { + Some(r) => format!("{r}{trailing}"), + None => token.to_string(), + }); + } + + out.join("") +} + +/// What one token should be replaced with, or `None` to keep it. +fn classify(word: &str) -> Option { + // `key=value` and `key: value` written as one token. Split at the first + // separator so `Authorization:Bearer` loses the half that matters. + if let Some((head, tail)) = word.split_once(['=', ':']) { + if names_a_secret(head) && !tail.is_empty() { + return Some(format!("{head}=")); + } + } + + // Strip the punctuation a sentence puts around a path — "opening + // /home/x/y.CR3:" — so the classification sees the path itself, then put + // nothing back: the punctuation is not worth the complexity of restoring + // it around a placeholder. + let bare = word.trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';' | ':')); + + if bare.contains('/') { + // A Rust source path keeps its basename. Line and column are part of + // the same token in a backtrace ("src/library.rs:1270:9"), and they + // are kept: they are facts about this codebase. + if let Some(rs) = rust_source_tail(bare) { + return Some(format!("…/{rs}")); + } + return Some("".to_string()); + } + if bare.starts_with('~') { + return Some("".to_string()); + } + if looks_opaque(bare) { + return Some("".to_string()); + } + None +} + +/// The `library.rs:1270:9` tail of a path naming a Rust source file. +fn rust_source_tail(word: &str) -> Option<&str> { + let tail = word.rsplit('/').next()?; + // The extension is followed by `:line:col` in a backtrace and by nothing + // in a `Location`, so match on the extension rather than on the end. + if tail.contains(".rs") { + Some(tail) + } else { + None + } +} + +/// Whether this word introduces a value that must not be written down. +fn names_a_secret(word: &str) -> bool { + let w = word + .trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') + .to_ascii_lowercase(); + matches!( + w.as_str(), + "password" + | "passwd" + | "app-password" + | "app_password" + | "token" + | "secret" + | "bearer" + | "authorization" + | "apikey" + | "api-key" + | "api_key" + | "credential" + | "credentials" + | "cookie" + ) +} + +/// Whether a word looks like a key rather than like prose. +/// +/// A long run of the characters secrets are encoded in, containing both a +/// letter and a digit — which is what an app password, a bearer token or a +/// base64 blob looks like, and what an English word or a Rust identifier does +/// not. +fn looks_opaque(word: &str) -> bool { + word.len() >= 20 + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '_' | '-' | '=')) + && word.chars().any(|c| c.is_ascii_digit()) + && word.chars().any(|c| c.is_ascii_alphabetic()) +} + +/// Cut `s` to `max` bytes on a character boundary, saying that it was cut. +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}… ({} bytes truncated)", &s[..end], s.len() - end) +} + +/// Seconds since the epoch, or 0 if the clock is before it. +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_users_photograph_never_reaches_a_record() { + let r = redact("failed to open /home/anna/Photos/2019 Divorce/IMG_0042.CR3"); + assert!(!r.contains("anna"), "{r}"); + assert!(!r.contains("IMG_0042"), "{r}"); + assert!(!r.contains("Divorce"), "{r}"); + assert!(r.contains("failed to open"), "the diagnosis was lost: {r}"); + } + + #[test] + fn an_android_document_uri_is_a_path_too() { + // SAF hands out `content://` URIs and `primary:DCIM/...` refs rather + // than paths, and both name the user's library just as precisely. + let r = redact("no such image: content://com.android.providers/tree/primary%3ADCIM"); + assert!(!r.contains("DCIM"), "{r}"); + let r = redact("source_ref primary:DCIM/Camera/IMG_1.CR3 missing"); + assert!(!r.contains("IMG_1"), "{r}"); + } + + #[test] + fn a_server_url_is_removed_because_it_names_the_user() { + // A Nextcloud URL is the user's own server, often with their login in + // the path. NFR-SEC-3 is about the wire; this is about the disk. + let r = redact("PROPFIND https://cloud.example.org/remote.php/dav/files/anna/ failed"); + assert!(!r.contains("cloud.example.org"), "{r}"); + assert!(!r.contains("anna"), "{r}"); + assert!(r.contains("PROPFIND"), "{r}"); + } + + #[test] + fn a_credential_never_reaches_a_record() { + // NFR-SEC-2, which forbids credentials in logs and plain files. Both + // spellings: the value beside a naming word, and the value alone. + let r = redact("auth failed: password hunter2correcthorse"); + assert!(!r.contains("hunter2correcthorse"), "{r}"); + + let r = redact("Authorization: Bearer aGVsbG90aGVyZTEyMzQ1Njc4OTA="); + assert!(!r.contains("aGVsbG90aGVyZTEyMzQ1Njc4OTA"), "{r}"); + + let r = redact("rejected app-password=abcde-fghij-12345-klmno-pqrst"); + assert!(!r.contains("abcde-fghij"), "{r}"); + } + + #[test] + fn a_bare_secret_is_caught_by_its_shape() { + // Nextcloud app passwords arrive with no label at all when they are + // interpolated into a message by a library this codebase does not own. + let r = redact("login failed for aBcDe1FgHiJ2kLmNo3PqRsT4uV"); + assert!(!r.contains("aBcDe1FgHiJ"), "{r}"); + } + + #[test] + fn a_backtrace_keeps_the_frames_that_make_it_readable() { + // The whole reason the `.rs` exception exists: redacting these to + // `` leaves a backtrace of nothing but symbol names, and the + // line number is a fact about this codebase rather than about anyone. + let r = redact( + " 3: dr_ui::library::run_scan\n at ./ui/dr-ui/src/library.rs:1270:9\n", + ); + assert!(r.contains("library.rs:1270:9"), "{r}"); + assert!(r.contains("dr_ui::library::run_scan"), "{r}"); + assert!(!r.contains("ui/dr-ui/src"), "{r}"); + // And the shape survives, because a backtrace is read as a shape. + assert!(r.contains('\n'), "{r}"); + assert!(r.starts_with(" 3:"), "{r}"); + } + + #[test] + fn ordinary_words_are_left_alone() { + // Over-redaction has a cost too: a record that says nothing is not + // safer, it is just useless. + let msg = "assertion failed: tier_desired was 3, expected 2"; + assert_eq!(redact(msg), msg); + } + + #[test] + fn an_enormous_payload_is_capped() { + // A panic that formatted a decoded buffer — or, the case NFR-SEC-5 + // cares about, an embedding — into its message. + let huge = "9".repeat(MAX_MESSAGE * 3); + let cut = truncate(&huge, MAX_MESSAGE); + assert!(cut.len() < huge.len()); + assert!(cut.contains("truncated"), "{cut}"); + } + + #[test] + fn truncation_does_not_split_a_character() { + let s = "é".repeat(100); + // 3 is mid-character for a 2-byte encoding. + let cut = truncate(&s, 3); + assert!(cut.starts_with('é')); + } + + #[test] + fn records_are_written_and_rotated() { + let dir = std::env::temp_dir().join(format!("dr-crash-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + + for i in 0..(KEEP_RECORDS as i64 + 5) { + write_record( + &dir, + &Record { + body: format!("darkroom-crash 1\nmessage: {i}\n"), + summary: String::new(), + // Distinct seconds, or the pid-suffixed names would + // collide and the rotation would have nothing to count. + when: 1_000 + i, + }, + ) + .unwrap(); + } + + let kept: Vec = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.path()) + .collect(); + assert_eq!(kept.len(), KEEP_RECORDS); + // The newest survive, not the oldest. + let newest = kept.iter().filter_map(|p| timestamp_of(p)).max(); + let oldest = kept.iter().filter_map(|p| timestamp_of(p)).min(); + assert_eq!(newest, Some(1_000 + KEEP_RECORDS as i64 + 4)); + assert_eq!(oldest, Some(1_000 + 5)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_stray_file_is_neither_listed_nor_pruned() { + let dir = std::env::temp_dir().join(format!("dr-crash-stray-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("darkroom.log"), b"not a crash").unwrap(); + + for i in 0..(KEEP_RECORDS as i64 + 5) { + write_record( + &dir, + &Record { + body: String::new(), + summary: String::new(), + when: 2_000 + i, + }, + ) + .unwrap(); + } + + assert!(dir.join("darkroom.log").is_file(), "the log was pruned"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_state_directory_is_neither_config_nor_cache() { + // NFR-OPS-1 puts the log here too, and NFR-OPS-3 keeps preferences + // separate from both. The distinction is what stops a crash record + // being swept away by the thing that is allowed to sweep caches. + let dir = state_dir(); + let s = dir.to_string_lossy(); + assert!(s.ends_with("darkroom"), "{s}"); + assert!(!s.contains("/cache"), "{s}"); + } +} diff --git a/platform/dr-plat/src/diagnostics.rs b/platform/dr-plat/src/diagnostics.rs new file mode 100644 index 0000000..1edad86 --- /dev/null +++ b/platform/dr-plat/src/diagnostics.rs @@ -0,0 +1,617 @@ +//! TRACES: NFR-OPS-1 | NFR-SEC-2 +//! A log the photographer still has tomorrow. +//! +//! Until this existed, everything this application knew about a failure went +//! to stderr on the desktop and to logcat on Android, and both are gone the +//! moment the terminal closes or the ring buffer wraps. That is tolerable when +//! the person debugging is sitting at the machine. It is useless for the case +//! this module is built for and which NFR-OPS-1 describes: **somebody +//! reproduces a bug on a tablet, and then sends us a file.** +//! +//! Android makes that the *normal* case rather than an awkward one. A +//! `NativeActivity` has no console. `adb logcat` requires a cable, developer +//! mode, and — crucially — that you were already watching when it happened. +//! The failures most worth catching are the ones that are one line long and +//! scroll past: a class the activity's loader could not find, a permission +//! that was refused, a root the system revoked while the app was backgrounded. +//! A file survives all three. +//! +//! # The shape of it +//! +//! One [`LogFile`] in [`crate::state::state_dir`], teed with whatever logger +//! the platform already had rather than replacing it — logcat stays exactly as +//! it was, which matters because losing it while debugging would make this a +//! downgrade. [`install`] is the whole of the wiring. +//! +//! Three properties, each of which is a test below: +//! +//! * **Capped.** Two files of [`MAX_FILE_BYTES`], so the worst case is stated +//! rather than discovered on a full phone. +//! * **Redacted at the sink** ([`redact`]), not at the call sites. A rule +//! every author has to remember is not a rule. +//! * **Durable per line.** No `BufWriter` anywhere, which is deliberate and +//! costs a syscall per record: the line worth having is the last one before +//! the process died, and on Android processes do not die politely — the +//! system kills them, with no unwinding and no `Drop`. A buffered log loses +//! precisely the evidence it was kept for. +//! +//! # Where the file lands +//! +//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else +//! `~/.local/state/darkroom/darkroom.log`. +//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`, +//! which `adb pull` reads from an ordinary release build. See +//! [`crate::state`] for why not the internal directory, and +//! [`redact`] for the consequence. +//! +//! # How crash capture should reach this +//! +//! It should not open its own file. A panic hook that calls `log::error!` +//! lands here like anything else, and lands *completely*, because writes are +//! unbuffered and per record — that is the property NFR-OPS-2 needs from +//! NFR-OPS-1 and the reason the two are not the same file. For a crash record +//! written separately, [`log_path`] says which log belongs with it and +//! [`redact::redact`] is public so the record gets the same treatment as the +//! lines around it. + +pub mod redact; + +use std::fmt::Write as _; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use log::{LevelFilter, Log, Metadata, Record}; + +/// The current log, and — after one rotation — `darkroom.log.1` beside it. +const FILE_NAME: &str = "darkroom.log"; + +/// The stated cap, per file. With [`RETAINED_GENERATIONS`] the application +/// occupies at most 8 MiB of the state directory, ever. +/// +/// Chosen against the retrieval, not against the disk: this is roughly forty +/// thousand lines at `info`, which is a long session's worth of context, and +/// it is small enough that `adb pull` over USB is instant and a user can +/// attach it to a message without thinking about it. A larger cap would buy +/// history nobody reads at the price of a file nobody sends. +pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; + +/// How many rotated files are kept behind the current one. +/// +/// One. The reason to keep any is that the interesting event often precedes +/// the rotation that a burst of retry logging then caused; the reason not to +/// keep more is that the worst case has to be a number this module can state. +pub const RETAINED_GENERATIONS: usize = 1; + +/// The longest one record may be before it is cut short. +/// +/// A guard against a single `{:?}` on something large — a decoded buffer, a +/// face embedding, an entire WebDAV response — turning the whole cap into one +/// unreadable line and evicting everything that led up to it. Generous enough +/// that no real message reaches it. +pub const MAX_LINE_BYTES: usize = 8 * 1024; + +const TRUNCATION_MARK: &str = "…[truncated]"; + +/// Set by [`install`] once, so crash capture can name the file without +/// re-deriving the directory and possibly disagreeing about it. +static LOG_PATH: OnceLock = OnceLock::new(); + +/// What [`install`] managed, said as data rather than as a log line — because +/// at the moment it is decided there is no logger to say it with. +/// +/// The caller logs it as the first line of the session, which is how the file +/// ends up naming itself to anyone reading logcat over the user's shoulder. +#[derive(Debug, Clone)] +pub enum Installed { + /// The tee is live and this is the file to ask the user for. + ToFile(PathBuf), + /// Logging works, but only where it always did, and this is why. A + /// read-only or missing state directory is not a reason to start the + /// application without a logger. + ConsoleOnly(String), +} + +/// Where this session is logging, if it is logging to a file at all. +pub fn log_path() -> Option { + LOG_PATH.get().cloned() +} + +/// Install the file sink alongside the logger the platform already provides. +/// +/// `console` is the platform's own logger — `env_logger`'s on the desktop, +/// `android_logger`'s on a device — and it keeps receiving every record it +/// would have received. `level` is the maximum level to let through globally; +/// pass whatever the console logger was built to filter at, so the two agree. +/// +/// Call it exactly once, first thing, and *after* +/// [`crate::state::set_state_dir`] on any platform that needs to declare a +/// directory. +pub fn install(console: Box, level: LevelFilter) -> Installed { + let dir = crate::state::state_dir(); + + let (file, outcome) = match LogFile::open_in(&dir, MAX_FILE_BYTES) { + Ok(file) => { + let path = file.path().to_path_buf(); + let _ = LOG_PATH.set(path.clone()); + (Some(file), Installed::ToFile(path)) + } + Err(e) => ( + None, + Installed::ConsoleOnly(format!("{} is not writable: {e}", dir.display())), + ), + }; + + if log::set_boxed_logger(Box::new(Tee { console, file })).is_err() { + // Something installed a logger before us. Saying so is the only useful + // response: whatever it was is still logging, and the file is not. + return Installed::ConsoleOnly("a logger was already installed".to_string()); + } + log::set_max_level(level); + outcome +} + +/// The platform's logger and ours, both fed from every record. +struct Tee { + console: Box, + file: Option, +} + +impl Log for Tee { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + self.console.enabled(metadata) + } + + fn log(&self, record: &Record<'_>) { + // The console first, and unconditionally: it applies its own filter, + // and on Android it is the thing a developer is watching live. + self.console.log(record); + + if let Some(file) = &self.file { + // The same gate the console applies, so the file is a transcript + // of logcat rather than a different log with different contents. + // It can be a slight superset — `env_logger`'s message-regex + // filter is applied inside its `log` and not by `enabled` — and a + // superset is the right way round for a file nobody is watching. + if self.console.enabled(record.metadata()) { + file.write_line(&format_record(record)); + } + } + } + + fn flush(&self) { + self.console.flush(); + // Nothing to do for the file: there is no buffer to flush, which is + // the point. See the module documentation. + } +} + +/// The log file itself, with its rotation. +/// +/// Cheap to construct and safe to share: every write takes a mutex, and the +/// formatting and redaction that precede it happen outside the lock, so a +/// thread logging cannot hold up an unrelated one for longer than a `write`. +pub struct LogFile { + path: PathBuf, + /// The single retained generation. Named once here rather than derived at + /// each rotation, so the two cannot drift. + previous: PathBuf, + cap: u64, + open: Mutex, +} + +struct Open { + file: File, + /// Bytes in the *current* file. Seeded from the file's length on open + /// rather than from zero — otherwise an application restarted often enough + /// would append past the cap forever and NFR-OPS-1's "size-capped" would + /// hold only within a single run. + written: u64, +} + +impl LogFile { + /// Open (or create) the log in `dir`, creating the directory if needed. + /// + /// `cap` is per file; the directory holds at most `cap * 2`. Public with + /// an explicit cap because the tests need a small one — a rotation test + /// that had to write 4 MiB would be a rotation test nobody runs. + pub fn open_in(dir: &Path, cap: u64) -> io::Result { + fs::create_dir_all(dir)?; + let path = dir.join(FILE_NAME); + let file = open_append(&path)?; + // A failure to stat a file we just opened is not worth refusing to log + // over; treating it as empty costs at most one late rotation. + let written = file.metadata().map(|m| m.len()).unwrap_or(0); + Ok(Self { + path, + previous: dir.join(format!("{FILE_NAME}.1")), + cap, + open: Mutex::new(Open { file, written }), + }) + } + + /// The file to ask the user to send. + pub fn path(&self) -> &Path { + &self.path + } + + /// Redact, cap, and append one record. + /// + /// Infallible by design. A logger that panicked, or that tried to report + /// its own failure through `log`, would take the application down or + /// recurse; a log line lost to a full disk is a smaller problem than + /// either. + pub fn write_line(&self, line: &str) { + // Both before the lock: neither can block, and neither should make + // another thread's `write` wait. + let mut line = redact::redact(line); + truncate_to(&mut line, MAX_LINE_BYTES); + line.push('\n'); + + // A poisoned mutex means another thread panicked mid-write. The log is + // exactly what is wanted at that moment, so carry on with whatever + // state it left: the worst case is one interleaved line. + let mut open = self.open.lock().unwrap_or_else(|e| e.into_inner()); + + let len = line.len() as u64; + if open.written > 0 && open.written + len > self.cap { + // A failed rotation is not a reason to drop the record. The only + // way `rename` fails on a directory we opened a file in is that + // the directory has gone, in which case the write below fails too + // and the line is lost either way. + let _ = self.rotate(&mut open); + } + + if open.file.write_all(line.as_bytes()).is_ok() { + open.written += len; + } + } + + /// `darkroom.log` becomes `darkroom.log.1`; the previous `.1` is dropped. + /// + /// Rename rather than copy-and-truncate, so a reader holding the old file + /// keeps reading a whole file and never sees it shrink under them. The old + /// descriptor stays valid against the renamed inode until it is replaced + /// on the next line. + fn rotate(&self, open: &mut Open) -> io::Result<()> { + let _ = fs::remove_file(&self.previous); + fs::rename(&self.path, &self.previous)?; + open.file = open_append(&self.path)?; + open.written = 0; + Ok(()) + } +} + +fn open_append(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.create(true).append(true); + // The log names the user's photographs and the shape of their library, + // which is nobody else's business on a shared machine. Applied at + // creation only, which is all that is needed, and ignored by the + // FAT-derived filesystem Android presents as external storage — where the + // directory is already scoped to this application. + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options.open(path) +} + +/// One record as one line. +/// +/// The thread name is here and not in `env_logger`'s default format because of +/// what it costs to leave out on Android: the failure this module was written +/// to catch is a class the activity's loader will not resolve, and whether the +/// call came from `android_main`'s thread or from a worker is the entire +/// diagnosis. Unnamed threads report their id instead of nothing, so two +/// interleaved workers can still be told apart. +fn format_record(record: &Record<'_>) -> String { + let mut line = String::with_capacity(128); + push_timestamp(&mut line, SystemTime::now()); + + let thread = std::thread::current(); + let _ = match thread.name() { + Some(name) => write!(line, " {:<5} [{name}]", record.level()), + None => write!(line, " {:<5} [{:?}]", record.level(), thread.id()), + }; + let _ = write!(line, " {}: {}", record.target(), record.args()); + line +} + +/// `2026-08-30T12:34:56.789Z`, in UTC. +/// +/// Hand-rolled rather than reached for from a crate. `chrono` and `jiff` are +/// both already in the lock file, and pulling either into `dr-plat` would put +/// a new dependency in the Android cross-compile for twenty lines of +/// arithmetic that cannot change — which is the same reasoning the workspace +/// manifest applies to TLS, SQLite and lens profiles, at a much smaller scale. +/// +/// UTC and not local time, deliberately: a log is read by somebody other than +/// the person who produced it, and a timestamp with no zone in it is a +/// timestamp that has to be guessed at. Milliseconds are there to line the +/// file up against a logcat capture of the same session. +fn push_timestamp(out: &mut String, now: SystemTime) { + let since_epoch = now.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = since_epoch.as_secs() as i64; + let millis = since_epoch.subsec_millis(); + + // `div_euclid`, not `/`: the truncating division would put every instant + // in the six hours before 1970 on the wrong day. A clock that has not been + // set yet reports one of them. + let (year, month, day) = civil_from_days(secs.div_euclid(86_400)); + let second_of_day = secs.rem_euclid(86_400); + + let _ = write!( + out, + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{millis:03}Z", + second_of_day / 3600, + (second_of_day % 3600) / 60, + second_of_day % 60, + ); +} + +/// Days since 1970-01-01 to a proleptic Gregorian date. +/// +/// Howard Hinnant's `civil_from_days`, which is the algorithm every date +/// library uses underneath: it shifts the epoch to 0000-03-01 so that the +/// leap day falls at the end of the year and the 400-year cycle divides +/// evenly, which is what removes every branch. +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + // Split out rather than written inline: `if a { x } else { y } / n` is a + // parse hazard in Rust, and getting it wrong here would be a silent + // off-by-a-century for dates before 1970 rather than a compile error. + let toward_negative_infinity = if z >= 0 { z } else { z - 146_096 }; + let era = toward_negative_infinity / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_shifted = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_shifted + 2) / 5 + 1; + let month = if month_shifted < 10 { + month_shifted + 3 + } else { + month_shifted - 9 + }; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +/// Cut a line to `max` bytes without splitting a character in half. +fn truncate_to(line: &mut String, max: usize) { + if line.len() <= max { + return; + } + let mut cut = max.saturating_sub(TRUNCATION_MARK.len()); + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + line.truncate(cut); + line.push_str(TRUNCATION_MARK); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An empty directory of this test's own. The convention elsewhere in the + /// workspace (`dr_decode::base_curve`), and it matters more here: two + /// tests sharing a log directory would rotate each other's files. + fn a_log_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("darkroom-diagnostics-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("a writable temp directory"); + dir + } + + fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_default() + } + + /// Long enough that a handful of them cross a small cap, short enough that + /// the arithmetic in each test stays readable. + fn a_line(n: usize) -> String { + format!("{n:04} scanning /home/duncan/Pictures/2026/Iceland for images") + } + + #[test] + fn the_log_stays_under_its_stated_cap() { + // **This is NFR-OPS-1's "size-capped".** Without rotation a phone left + // running fills its state directory and the failure lands on the user + // as "storage full", days after the logging that caused it. + let dir = a_log_dir("cap"); + let cap = 4096; + let log = LogFile::open_in(&dir, cap).expect("opens"); + + for n in 0..500 { + log.write_line(&a_line(n)); + } + + let current = fs::metadata(dir.join(FILE_NAME)) + .expect("a current log") + .len(); + assert!( + current <= cap, + "the current log is {current} bytes, over {cap}" + ); + + let previous = fs::metadata(dir.join(format!("{FILE_NAME}.1"))) + .expect("a rotated log, since 500 lines cannot fit in 4 KiB") + .len(); + assert!( + previous <= cap, + "the rotated log is {previous} bytes, over {cap}" + ); + + assert!( + !dir.join(format!("{FILE_NAME}.2")).exists(), + "only {RETAINED_GENERATIONS} generation is kept; a second would make the \ + worst case unstatable" + ); + assert!( + current + previous <= cap * 2, + "the whole directory must fit in twice the cap" + ); + } + + #[test] + fn rotation_keeps_the_newest_lines_and_drops_the_oldest() { + // Which half survives is the whole value of the feature: the user + // reports a bug and the interesting lines are the last ones. + let dir = a_log_dir("newest"); + let log = LogFile::open_in(&dir, 4096).expect("opens"); + for n in 0..500 { + log.write_line(&a_line(n)); + } + + let current = read(&dir.join(FILE_NAME)); + let previous = read(&dir.join(format!("{FILE_NAME}.1"))); + assert!( + current.contains(&a_line(499)), + "the last line written must be in the current log" + ); + let oldest = a_line(0); + assert!( + !current.contains(&oldest) && !previous.contains(&oldest), + "the oldest lines are what a cap gives up" + ); + } + + #[test] + fn a_line_survives_a_process_restart() { + // The requirement in one test. Everything else about this module is an + // implementation detail of "the user reproduces it, quits, and sends + // us the file". + let dir = a_log_dir("restart"); + { + let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens"); + log.write_line("the failure the user is about to report"); + } // the process ends here + + let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("reopens"); + log.write_line("the next launch"); + + let text = read(&dir.join(FILE_NAME)); + assert!( + text.contains("the failure the user is about to report"), + "a restart truncated the log: {text}" + ); + assert!(text.contains("the next launch")); + assert!( + text.find("the failure") < text.find("the next launch"), + "the second session must append, not prepend" + ); + } + + #[test] + fn the_cap_survives_a_restart_too() { + // The bug this exists to prevent: seeding `written` from zero on open + // rather than from the file's length. Nothing looks wrong until an + // application that restarts often has a log of unbounded size, which + // is the one failure mode a cap is for. + let dir = a_log_dir("restart-cap"); + let cap = 2048; + for _ in 0..40 { + let log = LogFile::open_in(&dir, cap).expect("opens"); + for n in 0..5 { + log.write_line(&a_line(n)); + } + } + + let current = fs::metadata(dir.join(FILE_NAME)) + .expect("a current log") + .len(); + assert!( + current <= cap, + "forty restarts appended {current} bytes into a {cap}-byte file" + ); + } + + #[test] + fn a_credential_never_reaches_the_file() { + // **NFR-SEC-2 at the sink**, which is the placement the requirement + // depends on: the call site here has done nothing wrong that a + // reviewer would catch, and the credential still does not land. + let dir = a_log_dir("redaction"); + let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens"); + log.write_line(r#"login response {"loginName":"duncan","appPassword":"wYh3K8mLpQr2"}"#); + log.write_line("PUT https://duncan:hunter2@cloud.example/remote.php/dav failed"); + + let text = read(&dir.join(FILE_NAME)); + assert!( + !text.contains("wYh3K8mLpQr2"), + "an app password reached the log: {text}" + ); + assert!( + !text.contains("hunter2"), + "a URL password reached the log: {text}" + ); + assert!( + text.contains("cloud.example") && text.contains("duncan"), + "the diagnostic part must survive the redaction: {text}" + ); + } + + #[test] + fn one_enormous_record_cannot_evict_the_whole_log() { + // A `{:?}` on something large — a decoded buffer, an embedding — must + // cost a line rather than the file. + let dir = a_log_dir("truncation"); + let log = LogFile::open_in(&dir, MAX_FILE_BYTES).expect("opens"); + log.write_line("a line worth keeping"); + log.write_line(&"x".repeat(MAX_LINE_BYTES * 4)); + + let text = read(&dir.join(FILE_NAME)); + assert!(text.contains("a line worth keeping")); + assert!( + text.contains(TRUNCATION_MARK), + "the oversized record should have been cut short" + ); + assert!( + text.len() < MAX_LINE_BYTES * 2, + "the oversized record was written whole: {} bytes", + text.len() + ); + } + + #[test] + fn truncation_never_splits_a_character() { + let mut line = "é".repeat(64); + truncate_to(&mut line, 40); + // The assertion is that this is still a `String` at all — the failure + // is a panic inside `truncate` on a non-boundary index — plus that it + // says it was cut. + assert!(line.ends_with(TRUNCATION_MARK)); + assert!(line.len() <= 40); + } + + #[test] + fn the_timestamp_is_utc_and_sortable() { + let mut out = String::new(); + let an_instant = UNIX_EPOCH + std::time::Duration::from_millis(1_700_000_000_123); + push_timestamp(&mut out, an_instant); + assert_eq!(out, "2023-11-14T22:13:20.123Z"); + + let mut epoch = String::new(); + push_timestamp(&mut epoch, UNIX_EPOCH); + assert_eq!(epoch, "1970-01-01T00:00:00.000Z"); + + // Lexicographic order is chronological order, which is what makes + // `sort` and a plain `grep` on a date prefix work. + assert!(epoch < out); + } + + #[test] + fn the_calendar_handles_the_cases_that_are_usually_wrong() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + // 2024 is a leap year; 2100 will not be, and the century rule is the + // one every hand-rolled calendar gets wrong. + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + assert_eq!(civil_from_days(-1), (1969, 12, 31)); + } +} diff --git a/platform/dr-plat/src/diagnostics/redact.rs b/platform/dr-plat/src/diagnostics/redact.rs new file mode 100644 index 0000000..ed37869 --- /dev/null +++ b/platform/dr-plat/src/diagnostics/redact.rs @@ -0,0 +1,473 @@ +//! TRACES: NFR-SEC-2 +//! Taking the credentials back out of a line that already contains them. +//! +//! NFR-SEC-2 says credentials are never written to logs. NFR-OPS-1 restates it +//! as an obligation on the diagnostics path specifically — "automatic +//! redaction of credentials and tokens" — and the word doing the work is +//! *automatic*. A rule that every call site must remember is not a rule; it is +//! a hope, and it fails the first time somebody debugging a 401 writes +//! `log::debug!("{response:?}")` at two in the morning and forgets to take it +//! out. So the redaction happens once, at the sink, on the formatted line, +//! after every call site has had its say. +//! +//! On Android this is not a defence-in-depth nicety. The log lives in the +//! app's *external* directory so that `adb pull` can reach it +//! ([`crate::state`]), which means anyone holding the device can read it, and +//! a Nextcloud app password in there is a working credential for the user's +//! whole server. +//! +//! # What it looks for, and why not a regex +//! +//! A credential in a log line is nearly always adjacent to a word that names +//! it — `appPassword`, `Authorization`, `token=` — because it got there +//! through a `Debug` impl, a serialised request, or a URL. That adjacency is +//! the only reliable signal there is: the app password Nextcloud issues is an +//! opaque 72-character string with no structure to match on, so a scanner that +//! hunted for *secret-shaped* text would find every base64 blob and every +//! content hash in the file and redact those instead. +//! +//! Hence a keyword scanner, hand-rolled rather than `regex`. The workspace has +//! no regex dependency and this is not worth acquiring one for — the grammar +//! is "a known word, an `=` or a `:`, a value" plus the two forms that carry a +//! credential with no keyword at all: an `Authorization` scheme and a URL's +//! userinfo. Three rules, each of which fits in a function and has a test. +//! +//! # What it deliberately leaves alone +//! +//! **Filesystem paths, and the names of the user's own photographs.** They are +//! not in NFR-SEC-2's list, they are not in NFR-SEC-5's, and they are the +//! single most useful thing in a log about a file that would not open. A +//! diagnostics file that says "failed to decode " is not a +//! diagnostic. The rule NFR-OPS-1 states — an explicit preview-and-consent +//! step before anything leaves the device — is the one that governs paths, and +//! it governs them better than scrubbing would, because it lets the user look. +//! +//! **Face data (NFR-SEC-5).** Not because it is permitted here, but because +//! this is the wrong instrument for it. An embedding is 512 floats; by the +//! time one has reached a formatted log line, redacting it would be a guess at +//! what a `[f32]` looks like in prose. NFR-SEC-5 makes confinement +//! *structural* — "the code path does not exist" — and the sink's contribution +//! to that is to not be one: it opens no bundle, uploads nothing, and adds no +//! new route off the device. The one thing it does do about the shape of the +//! problem is cap the line length (see [`super::MAX_LINE_BYTES`]), so a +//! `{embedding:?}` that should never have been written costs kilobytes rather +//! than megabytes and is obvious in the file rather than buried. + +/// What a redacted value is replaced by. Chosen to be greppable and obviously +/// not a value: a reader finding this in a log knows something was removed, +/// which an empty string or a row of asterisks would not tell them. +pub const PLACEHOLDER: &str = ""; + +/// The words that introduce a credential. +/// +/// Matched case-insensitively and only on a whole-word boundary, so `password` +/// does not fire inside `passwordless` and `token` does not fire inside +/// `tokenizer`. Longer entries are not redundant with shorter ones for the +/// same reason — `apppassword` has no boundary before its `password`, so +/// without its own entry `"appPassword": "…"` would sail through, and that is +/// the exact key Nextcloud's Login Flow v2 returns the credential under +/// (`dr_sync_nextcloud::auth::AppCredentials`). +const SENSITIVE_KEYS: &[&str] = &[ + "apppassword", + "app_password", + "password", + "passwd", + "pwd", + "access_token", + "refresh_token", + "id_token", + "apikey", + "api_key", + "authorization", + "credentials", + "credential", + "secret", + "token", +]; + +/// The `Authorization` schemes that carry the credential in the value rather +/// than after a keyword. `Basic dXNlcjpwdw==` is `login:password` in base64 — +/// reversible with one shell command — and every WebDAV request this app makes +/// is signed with one (`dr_sync_nextcloud`, `basic_auth`). +const AUTH_SCHEMES: &[&str] = &["basic ", "bearer "]; + +/// Remove anything that looks like a credential from one formatted line. +/// +/// Cheap enough for the write path: one ASCII-lowercased copy and a single +/// left-to-right pass, with no allocation per match beyond the output string. +/// Non-ASCII is untouched by the lowercasing, which is what keeps the byte +/// offsets of the copy and the original identical — `to_lowercase` would not, +/// and a one-byte drift would slice a message mid-character. +pub fn redact(line: &str) -> String { + let lower = line.to_ascii_lowercase(); + let low = lower.as_bytes(); + + let mut out = String::with_capacity(line.len()); + // Everything from here to the current position is verbatim text not yet + // copied, so an unmatched line copies itself in one `push_str`. + let mut copied = 0usize; + let mut i = 0usize; + + while i < low.len() { + match secret_at(low, i) { + Some((start, end)) => { + out.push_str(&line[copied..start]); + out.push_str(PLACEHOLDER); + copied = end; + i = end; + } + None => i += 1, + } + } + + out.push_str(&line[copied..]); + out +} + +/// The byte range of a secret beginning at or just after `i`, if there is one. +/// +/// Every rule is anchored on an ASCII byte, and every boundary it returns is +/// found by scanning for ASCII delimiters — which cannot occur inside a +/// multi-byte UTF-8 sequence — so the ranges are always char boundaries even +/// though the scan is over bytes. +fn secret_at(low: &[u8], i: usize) -> Option<(usize, usize)> { + if let Some(range) = keyed_value_at(low, i) { + return Some(range); + } + if let Some(range) = bare_scheme_at(low, i) { + return Some(range); + } + url_userinfo_at(low, i) +} + +/// `password=hunter2`, `"appPassword": "…"`, `Authorization: Basic …`. +fn keyed_value_at(low: &[u8], i: usize) -> Option<(usize, usize)> { + if i > 0 && is_word_byte(low[i - 1]) { + return None; + } + for key in SENSITIVE_KEYS { + if !low[i..].starts_with(key.as_bytes()) { + continue; + } + let after = i + key.len(); + // A whole word, so `tokenizer` is not a token and `secretary` is not + // a secret. `continue` rather than `return`: a short key failing its + // boundary test must not mask a longer one that would have passed, + // which is how `credential` would otherwise swallow `credentials`. + if low.get(after).is_some_and(|b| is_word_byte(*b)) { + continue; + } + return value_after_assignment(low, after); + } + None +} + +/// The value introduced by an `=` or a `:` following a key. +/// +/// Requiring one of those two is what keeps prose out of it. Plenty of log +/// lines say the word — "password rejected", "no token yet" — and a rule that +/// redacted the next word after any mention of a credential would make those +/// lines unreadable while protecting nothing. +fn value_after_assignment(low: &[u8], mut j: usize) -> Option<(usize, usize)> { + j = skip_spaces(low, j); + // The closing quote of a quoted key: `"password": "…"`. + if matches!(low.get(j), Some(b'"' | b'\'')) { + j = skip_spaces(low, j + 1); + } + if !matches!(low.get(j), Some(b'=' | b':')) { + return None; + } + j = skip_spaces(low, j + 1); + + // An opening quote is stepped over rather than redacted, so the result is + // still shaped like what it replaced: `"password": ""` reads as + // a redacted field, `"password": ` reads as damage. + let quote = match low.get(j) { + Some(&q) if q == b'"' || q == b'\'' => { + j += 1; + Some(q) + } + _ => None, + }; + + // `Authorization: Basic dXNlcjpwdw==` — keep the scheme, take the rest. + // Which scheme was in use is diagnostic (a request signed as `Bearer` + // where the app only ever issues `Basic` is a bug worth seeing) and it is + // not itself a secret. + for scheme in AUTH_SCHEMES { + if low[j..].starts_with(scheme.as_bytes()) { + j = skip_spaces(low, j + scheme.len()); + break; + } + } + + let start = j; + let end = match quote { + Some(q) => low[start..] + .iter() + .position(|b| *b == q) + .map_or(low.len(), |n| start + n), + None => value_end(low, start), + }; + + // `password=` with nothing after it has nothing to hide, and replacing an + // empty value would only make the line say less. + (end > start).then_some((start, end)) +} + +/// `Basic dXNlcjpwdw==` on its own, with no keyword in front of it — a header +/// dumped by its value, or a `curl` line pasted into a message. +fn bare_scheme_at(low: &[u8], i: usize) -> Option<(usize, usize)> { + if i > 0 && is_word_byte(low[i - 1]) { + return None; + } + let scheme = AUTH_SCHEMES + .iter() + .find(|scheme| low[i..].starts_with(scheme.as_bytes()))?; + let start = skip_spaces(low, i + scheme.len()); + let end = value_end(low, start); + // Unlike the keyed rule, nothing here has established that a credential + // was intended — and `basic` is an ordinary English word. "using basic + // sRGB as the fallback" must survive, so the token itself has to look the + // part. + looks_like_a_credential(&low[start..end]).then_some((start, end)) +} + +/// Whether an unkeyed token is credential-shaped. +/// +/// Base64 is long and drawn from a fixed alphabet; a word of prose is neither. +/// Sixteen bytes is comfortably below the shortest thing that could be a real +/// `Basic` credential — base64 of `a:b` is already 8, and a Nextcloud app +/// password is 72 — and comfortably above the words that follow "basic" in a +/// sentence. +fn looks_like_a_credential(token: &[u8]) -> bool { + token.len() >= 16 + && token.iter().all(|b| { + b.is_ascii_alphanumeric() || matches!(*b, b'+' | b'/' | b'=' | b'-' | b'_' | b'.') + }) +} + +/// The password in `https://duncan:hunter2@cloud.example/remote.php/dav`. +/// +/// URLs are logged constantly and this form survives every keyword rule, since +/// the credential is punctuation-delimited rather than named. The host and the +/// login are left in place: which server failed, and as whom, is the substance +/// of a sync bug report. +fn url_userinfo_at(low: &[u8], i: usize) -> Option<(usize, usize)> { + if !low[i..].starts_with(b"://") { + return None; + } + let authority = i + 3; + // The authority ends at the path, the query, the fragment, or whatever + // punctuation the surrounding prose used to end the URL. + let end = low[authority..] + .iter() + .position(|b| matches!(*b, b'/' | b'?' | b'#') || is_value_delimiter(*b)) + .map_or(low.len(), |n| authority + n); + + // The *last* `@`, because a userinfo may legally contain a percent-encoded + // one and the host may not. + let at = authority + low[authority..end].iter().rposition(|b| *b == b'@')?; + let colon = authority + low[authority..at].iter().position(|b| *b == b':')?; + + (at > colon + 1).then_some((colon + 1, at)) +} + +fn skip_spaces(low: &[u8], mut j: usize) -> usize { + while matches!(low.get(j), Some(b' ' | b'\t')) { + j += 1; + } + j +} + +/// Where an unquoted value stops. +fn value_end(low: &[u8], start: usize) -> usize { + low[start..] + .iter() + .position(|b| is_value_delimiter(*b)) + .map_or(low.len(), |n| start + n) +} + +/// The punctuation that ends an unquoted value in every form this sees: JSON +/// (`,` `}` `]` `"`), a query string (`&` `;`), a `Debug` impl (`,` `)` `}`), +/// and prose (whitespace). +fn is_value_delimiter(b: u8) -> bool { + matches!( + b, + b' ' | b'\t' + | b'\r' + | b'\n' + | b'"' + | b'\'' + | b',' + | b';' + | b'&' + | b')' + | b']' + | b'}' + | b'>' + ) +} + +/// What counts as "inside a word" for the boundary test. `_` is included so +/// that `access_token` is one word rather than two, which is what stops the +/// `token` rule from firing in the middle of it and redacting from there. +fn is_word_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The strongest form of the assertion: not "the line changed" but "the + /// secret is gone". A rule that redacted the wrong span would pass a + /// weaker test. + fn assert_gone(secret: &str, line: &str) -> String { + let out = redact(line); + assert!( + !out.contains(secret), + "redaction left the credential behind\n in: {line}\n out: {out}" + ); + assert!( + out.contains(PLACEHOLDER), + "nothing was marked as removed: {out}" + ); + out + } + + #[test] + fn the_credential_login_flow_returns_never_reaches_the_file() { + // Verbatim the shape of `AppCredentials` as serde writes it — the one + // credential this application actually holds (FR-NC-1). + let out = assert_gone( + "wYh3K8mLpQr2", + r#"got {"server":"https://cloud.example","loginName":"duncan","appPassword":"wYh3K8mLpQr2"}"#, + ); + // The rest of the response is the diagnostic, and it survives. + assert!(out.contains("cloud.example")); + assert!(out.contains("duncan")); + } + + #[test] + fn a_debug_printed_credential_struct_is_scrubbed() { + // The two-in-the-morning case: `log::debug!("{creds:?}")`. + assert_gone( + "wYh3K8mLpQr2", + r#"AppCredentials { server: "https://cloud.example", login_name: "duncan", app_password: "wYh3K8mLpQr2" }"#, + ); + } + + #[test] + fn a_basic_auth_header_keeps_its_scheme_and_loses_its_secret() { + let out = assert_gone( + "ZHVuY2FuOmh1bnRlcjI=", + "PROPFIND /remote.php/dav authorization: Basic ZHVuY2FuOmh1bnRlcjI=", + ); + assert!( + out.contains("Basic"), + "which scheme was used is diagnostic, and is not the secret: {out}" + ); + } + + #[test] + fn a_bearer_token_with_no_keyword_in_front_of_it_still_goes() { + assert_gone( + "eyJhbGciOiJIUzI1NiJ9", + "sending Bearer eyJhbGciOiJIUzI1NiJ9", + ); + } + + #[test] + fn a_password_in_a_url_goes_and_the_server_stays() { + let out = assert_gone( + "hunter2", + "PUT https://duncan:hunter2@cloud.example/remote.php/dav/files/x.cr2 failed", + ); + assert!( + out.contains("duncan") && out.contains("cloud.example"), + "which server, and as whom, is the whole of a sync bug report: {out}" + ); + } + + #[test] + fn a_query_string_token_goes() { + assert_gone( + "abc123def", + "polling https://cloud.example/login/v2/poll?token=abc123def&x=1", + ); + } + + #[test] + fn the_word_alone_is_not_a_secret() { + // The over-redaction failure, which is how a scrubber makes a log + // useless without ever being caught: these lines say nothing secret + // and must come through untouched. + for line in [ + "password rejected by the server", + "no token yet; still polling", + "the tokenizer produced 41 tokens", + "authorization failed with 401", + "secretary@cloud.example added", + // The unkeyed `Basic ` rule has no keyword to justify itself, and + // `basic` is an ordinary word. This is the line it must not eat. + "using basic sRGB as the fallback", + "bearer of the news: 12 images", + ] { + assert_eq!(redact(line), line, "over-redacted: {line}"); + } + } + + #[test] + fn a_photographs_path_is_not_a_secret() { + // Stated as a test because it is a design decision and not an + // oversight: NFR-SEC-2 lists credentials, and a log that cannot name + // the file that failed to decode is not a diagnostic. See the module + // documentation. + let line = "decode failed: /home/duncan/Pictures/2026/Iceland/DSC_0431.NEF"; + assert_eq!(redact(line), line); + } + + #[test] + fn several_secrets_in_one_line_all_go() { + let out = redact(r#"{"password":"a1b2c3","token":"d4e5f6","user":"duncan"}"#); + assert!(!out.contains("a1b2c3"), "{out}"); + assert!(!out.contains("d4e5f6"), "{out}"); + assert!(out.contains("duncan"), "{out}"); + } + + #[test] + fn redaction_keeps_the_line_shaped_like_what_it_replaced() { + assert_eq!( + redact(r#"{"password": "hunter2"}"#), + r#"{"password": ""}"# + ); + assert_eq!(redact("password=hunter2"), "password="); + } + + #[test] + fn a_line_with_no_secret_survives_byte_for_byte() { + // Including non-ASCII, which is the case the ASCII lowercasing exists + // to protect: a `to_lowercase` here would shift byte offsets under a + // Turkish dotted capital and slice the next character in half. + for line in [ + "opened /home/duncan/Bilder/Ölandsbron/DSC_0431.NEF", + "İSTANBUL library scanned: 12 034 images", + "", + ] { + assert_eq!(redact(line), line); + } + } + + #[test] + fn a_url_without_a_credential_is_left_whole() { + let line = "GET https://cloud.example/remote.php/dav/files/duncan/"; + assert_eq!(redact(line), line); + } + + #[test] + fn an_email_address_is_not_a_url_userinfo() { + let line = "sharing with duncan@tourolle.paris"; + assert_eq!(redact(line), line); + } +} diff --git a/platform/dr-plat/src/lib.rs b/platform/dr-plat/src/lib.rs index 808ed80..d92e17d 100644 --- a/platform/dr-plat/src/lib.rs +++ b/platform/dr-plat/src/lib.rs @@ -4,11 +4,21 @@ //! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1, //! ARCH §10). +// Neither of the next two is a trait, and they are the modules here that are +// not. They belong in this crate for the same reason the traits do: "where does +// this platform let an application keep state" is a platform question, and both +// entry points need the answer before either has a window. Resolving it in +// `apps/` would mean writing it twice, once per platform, which is the +// arrangement this crate exists to prevent. +pub mod crash; +pub mod diagnostics; pub mod display; pub mod secrets; +pub mod state; pub mod storage; pub mod volumes; +pub use diagnostics::{Installed, LogFile}; pub use display::{ Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason, ProfileSource, @@ -16,6 +26,7 @@ pub use display::{ pub use secrets::{ EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore, }; +pub use state::{set_state_dir, state_dir}; pub use storage::{ DirRef, Entry, LocalStorage, NewFile, Node, SeekableRead, Storage, StorageError, WritableStorage, diff --git a/platform/dr-plat/src/state.rs b/platform/dr-plat/src/state.rs new file mode 100644 index 0000000..f8efab1 --- /dev/null +++ b/platform/dr-plat/src/state.rs @@ -0,0 +1,145 @@ +//! TRACES: NFR-OPS-1 +//! Where this platform lets the application keep notes about itself. +//! +//! Not the catalog, not the photographs, not the user's configuration — those +//! three already have homes (`dr_sync::account::config_dir`, +//! `dr_ui::library::data_root`, and the sidecars beside the images). This is +//! the fourth thing: the diagnostic residue an application leaves so that a +//! failure yesterday can be read about today. A log, and — when the crash +//! path lands — a crash record. +//! +//! # Why it is its own directory and not one of the other three +//! +//! XDG separates them for a reason that is not tidiness. `$XDG_CONFIG_HOME` +//! is what the user has chosen and would miss; `$XDG_DATA_HOME` is what the +//! application built and would have to rebuild; `$XDG_STATE_HOME` is +//! "state that should persist between restarts but is not important or +//! portable enough for the data directory" — which is exactly a log file. It +//! is also the directory nobody backs up, and a log is the one file here we +//! actively want a user to be able to delete without consequence. +//! +//! # Android has none of those variables, so the platform must say +//! +//! There is no `$HOME` on Android and no XDG anything (ARCH §6.9), so the +//! guesses below resolve to a path the app cannot write. `dr_sync::account` +//! learned this the expensive way — the session list went to a doomed path, +//! nothing failed loudly, and backgrounding the app lost the sign-in — and the +//! shape of the fix is copied here deliberately: the platform entry point +//! declares the directory once, before anything opens a file in it. +//! +//! **Which Android directory is a diagnostics decision, and it belongs to the +//! caller.** `AndroidApp` offers two, and they differ in precisely the way +//! that matters here: +//! +//! * `internal_data_path` — `/data/data//files`. Private, durable, and +//! unreachable: pulling a file out of it needs `run-as` against a debuggable +//! build, or root. +//! * `external_data_path` — `/sdcard/Android/data//files`. Same lifetime +//! (the system deletes it with the app, not under storage pressure — that is +//! the *cache* directory), needs no permission since API 19, and `adb pull` +//! reads it from any build. +//! +//! A log nobody can retrieve is not a diagnostic, so `darkroom-android` points +//! this at the external one. That choice has a consequence, and it is the +//! reason [`crate::diagnostics`] redacts at the sink rather than trusting call +//! sites: everything written here is readable by anyone holding the device. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Declared once by the platform entry point; a guess otherwise. +static STATE_DIR: OnceLock = OnceLock::new(); + +/// Declare where this platform keeps application state. +/// +/// Call it before anything opens a file — installing the logger is normally +/// the very next line — because a later call is *ignored* rather than obeyed. +/// That is deliberate: two callers disagreeing about the directory would +/// otherwise split the log across two files depending on which ran first, and +/// a silently-ignored second call leaves one log rather than two halves. +/// +/// Desktop needs no call. The XDG resolution below is correct there. +pub fn set_state_dir(dir: PathBuf) { + let _ = STATE_DIR.set(dir); +} + +/// The directory this application's state belongs in. +/// +/// It is not created here. Whoever writes into it creates it, so that merely +/// asking the question leaves nothing behind on a machine that never logs. +pub fn state_dir() -> PathBuf { + if let Some(dir) = STATE_DIR.get() { + return dir.clone(); + } + xdg_state_dir(std::env::var_os("XDG_STATE_HOME"), std::env::var_os("HOME")) +} + +/// The XDG resolution, as a function of its inputs rather than of the process +/// environment, so it can be tested without `set_var` racing every other test +/// in the binary. +/// +/// Relative values are ignored rather than resolved against the working +/// directory: the base-directory specification says so explicitly, and the +/// alternative is a `darkroom/` directory appearing wherever the app was +/// launched from. +fn xdg_state_dir(xdg_state_home: Option, home: Option) -> PathBuf { + xdg_state_home + .filter(|value| Path::new(value).is_absolute()) + .map(PathBuf::from) + .or_else(|| { + home.filter(|value| Path::new(value).is_absolute()) + .map(|value| PathBuf::from(value).join(".local/state")) + }) + // A container or a systemd unit with neither variable set. Writing a + // log into `/tmp` is a poor outcome; refusing to log at all, on the + // one kind of machine nobody is sitting in front of, is a worse one. + .unwrap_or_else(std::env::temp_dir) + .join("darkroom") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_log_goes_under_the_state_directory_the_user_named() { + // NFR-OPS-1 says "the XDG state directory", and honouring + // $XDG_STATE_HOME is the whole of what that means to a user who has + // moved theirs. + let dir = xdg_state_dir(Some("/var/lib/dr".into()), Some("/home/someone".into())); + assert_eq!(dir, PathBuf::from("/var/lib/dr/darkroom")); + } + + #[test] + fn without_the_variable_it_is_the_specifications_default() { + let dir = xdg_state_dir(None, Some("/home/someone".into())); + assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom")); + } + + #[test] + fn a_relative_value_is_ignored_rather_than_resolved() { + // The specification requires this, and the failure it prevents is a + // `darkroom/` directory appearing in whatever the working directory + // happened to be — including, on a desktop launcher, `/`. + let dir = xdg_state_dir(Some("state".into()), Some("/home/someone".into())); + assert_eq!(dir, PathBuf::from("/home/someone/.local/state/darkroom")); + + let dir = xdg_state_dir(Some("".into()), Some("".into())); + assert!( + dir.is_absolute(), + "an empty HOME must not produce a relative state directory" + ); + } + + #[test] + fn a_declared_directory_is_declared_once() { + // The property the entry points rely on: two callers cannot split the + // log in half. Exercised on a fresh `OnceLock` rather than the global + // one, which any other test in this binary may already have set. + let cell: OnceLock = OnceLock::new(); + assert!(cell.set(PathBuf::from("/first")).is_ok()); + assert!(cell.set(PathBuf::from("/second")).is_err()); + assert_eq!(cell.get(), Some(&PathBuf::from("/first"))); + } +} diff --git a/platform/dr-plat/src/volumes.rs b/platform/dr-plat/src/volumes.rs index 0f0faa1..4d5a942 100644 --- a/platform/dr-plat/src/volumes.rs +++ b/platform/dr-plat/src/volumes.rs @@ -59,7 +59,7 @@ impl Volume { } } -/// TRACES: FR-CAT-10 | FR-PLAT-AND-1 | NFR-PORT-1 +/// TRACES: FR-CAT-10 | NFR-PORT-1 /// Whether an import can reach a card on this platform at all. /// /// **Not the same question as "did [`volumes`] find anything".** An empty list diff --git a/tools/bench/Cargo.toml b/tools/bench/Cargo.toml new file mode 100644 index 0000000..2ed24d8 --- /dev/null +++ b/tools/bench/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "dr-bench" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +# The suite deliberately depends on no GPU crate and no UI toolkit. +# +# That is not tidiness, it is what makes the CI job affordable: `cargo build +# --release -p dr-bench` resolves the catalog, the decoder, the thumbnail store +# and the encoder, and stops there. Adding `dr-gpu` would pull wgpu and naga +# into a job that has no adapter to use them with, and adding `dr-ui` would +# pull Slint. The GPU half of the suite is `dr-gpu`'s own frame-budget test, +# which already exists and already skips itself where there is no device — see +# `docs/benchmarks.md`. +[dependencies] +dr-types.workspace = true +dr-catalog.workspace = true +dr-thumbs.workspace = true +dr-decode.workspace = true +dr-export.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +log.workspace = true +env_logger.workspace = true diff --git a/tools/bench/src/baseline.rs b/tools/bench/src/baseline.rs new file mode 100644 index 0000000..d8321b1 --- /dev/null +++ b/tools/bench/src/baseline.rs @@ -0,0 +1,300 @@ +//! The committed numbers, and what counts as a regression against them. +//! +//! `docs/frame-budget.md` commits its measurements by hand and says why: *"a +//! regression should be a diff rather than somebody's memory."* This is the +//! same idea in a form a program can read, because §8 asks for more than a +//! record — *"a regression beyond stated tolerance fails the build"*. +//! +//! # Two gates, and they are not the same gate +//! +//! **The budget** is the requirement's own number: 2 s to open a catalog, 100 +//! images a second through the preview path. It does not move. A build that +//! violates it has violated a requirement, and no amount of "but it was always +//! like that" changes it. +//! +//! **The baseline** is what this machine last measured. It moves — deliberately +//! and by hand, through `dr-bench record` — and its job is to catch the change +//! that is still inside the budget but has halved the headroom. Most real +//! performance rot arrives that way: never over the line, always a little +//! worse, until one day the line is crossed by a change that was not the cause. +//! +//! # Why a machine-sensitive metric skips its budget off the reference desktop +//! +//! §8 names *"the reference desktop"*, not CI, and it is right to. A container +//! with two cores cannot speak to a throughput target written for a +//! twenty-four-thread machine, and asserting it there would produce exactly +//! what `core/dr-gpu/tests/frame_budget.rs` refused to produce: *"a red suite +//! that everyone learns to ignore"*. So a metric declares whether its budget +//! is machine-sensitive. Those budgets are asserted under `--reference` and +//! reported everywhere else; the ones with orders of magnitude of headroom — +//! catalog open against two seconds — are asserted everywhere, because a +//! failure there is a real failure on any machine. +//! +//! # Why the committed file starts with no numbers in it +//! +//! Because nobody had run it yet. Writing plausible-looking figures into a +//! baseline is the one thing that would make the whole suite worthless: every +//! later comparison would be against a guess, and the first genuine regression +//! would be invisible or, worse, a fabricated improvement. `recorded` is +//! therefore `null` until somebody runs `dr-bench record --reference` on the +//! reference desktop and commits the diff. Until then the budget gate works +//! and the regression gate says so rather than pretending. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::fixture::Stamp; + +/// Which way is better. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + LowerIsBetter, + HigherIsBetter, +} + +/// One measured quantity: what it is, what it must be, and what it was. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Metric { + /// The requirement ID this speaks to, or a note saying it speaks to only + /// part of one. Free text on purpose: several of these describe a fraction + /// of a requirement, and a bare ID here would read as the whole of it. + pub requirement: String, + /// One sentence a reader of the JSON can understand without the code. + pub what: String, + pub unit: String, + pub direction: Direction, + /// Whether the budget below is a statement about a machine as much as + /// about the code. See this module's header. + pub machine_sensitive: bool, + /// The requirement's own threshold, where it has one this can check. + pub budget: Option, + /// What the last `record` measured. `null` until one has been taken. + pub recorded: Option, +} + +/// The committed file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Baseline { + /// Prose for whoever opens the JSON first. Kept in the file rather than + /// only in `docs/benchmarks.md`, because the person who finds this in a + /// failing CI log is not reading the docs directory at that moment. + #[serde(rename = "_readme")] + pub readme: Vec, + /// How much worse than [`Metric::recorded`] a metric may get before the + /// build fails, as a fraction. 0.15 is fifteen per cent. + pub tolerance: f64, + /// The machine the recorded figures came from, as [`machine_id`] spells + /// it. Compared before a regression is judged: drift against a different + /// machine's numbers is not a regression, it is a different machine. + pub recorded_on: Option, + /// Unix seconds. An integer rather than a formatted date because this + /// workspace has no date library and adding one for a comment would be a + /// poor trade. + pub recorded_at_unix: Option, + /// The fixture the recorded figures describe. A number measured against a + /// different workload is not comparable, and this is what says so. + pub fixture: Option, + pub metrics: BTreeMap, +} + +/// How a measurement compares. +#[derive(Debug, Clone, Copy)] +pub struct Judgement { + /// Past the requirement's own threshold. A build failure. + pub over_budget: bool, + /// Worse than the recorded baseline by more than the tolerance. A build + /// failure. + pub regressed: bool, + /// Fractional change against the baseline, positive meaning worse. + pub drift: Option, + /// A budget exists but was not asserted, because it is machine-sensitive + /// and this is not the reference desktop. + pub budget_deferred: bool, +} + +/// What a judgement is made in the light of. +#[derive(Debug, Clone, Copy)] +pub struct Judging { + /// This run declares itself the reference desktop. + pub reference: bool, + /// This run is on the machine the baseline was recorded on. + pub same_machine: bool, + pub tolerance: f64, +} + +impl Metric { + /// Judge `measured` against the budget and the baseline. + pub fn judge(&self, measured: f64, cx: Judging) -> Judgement { + let violates = |threshold: f64| match self.direction { + Direction::LowerIsBetter => measured > threshold, + Direction::HigherIsBetter => measured < threshold, + }; + let assert_budget = self.budget.is_some() && (cx.reference || !self.machine_sensitive); + + let drift = match self.recorded { + // A recorded zero would divide by nothing, and a recorded figure + // of zero is a broken record rather than a very fast one. + Some(was) if was > 0.0 => Some(match self.direction { + Direction::LowerIsBetter => (measured - was) / was, + Direction::HigherIsBetter => (was - measured) / was, + }), + _ => None, + }; + + Judgement { + over_budget: assert_budget && self.budget.is_some_and(violates), + regressed: cx.same_machine && drift.is_some_and(|d| d > cx.tolerance), + drift, + budget_deferred: self.budget.is_some() && !assert_budget, + } + } +} + +impl Baseline { + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading the baseline at {}", path.display()))?; + serde_json::from_str(&text) + .with_context(|| format!("parsing the baseline at {}", path.display())) + } + + pub fn save(&self, path: &Path) -> Result<()> { + let mut text = serde_json::to_string_pretty(self)?; + // A trailing newline, so the file is a well-behaved text file and a + // `record` that changed nothing produces an empty diff. + text.push('\n'); + std::fs::write(path, text) + .with_context(|| format!("writing the baseline to {}", path.display())) + } + + /// Where the committed baseline lives, found the way `tools/traceability` + /// finds the repo root: by walking up from this crate's manifest until + /// `docs/requirements.md` appears. + pub fn default_path() -> Result { + let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + while !dir.join("docs/requirements.md").exists() { + if !dir.pop() { + anyhow::bail!("could not locate the repo root above this crate"); + } + } + Ok(dir.join("docs/bench-baseline.json")) + } +} + +/// How this machine is named in the baseline. +/// +/// Host name plus thread count. Not a hardware inventory — it exists to answer +/// one question, "are these numbers from here?", and to answer it the same way +/// twice on the same box. A container whose hostname changes per run therefore +/// never matches, which is the correct answer for CI: its drift is information, +/// not a verdict. +pub fn machine_id() -> String { + let host = std::fs::read_to_string("/proc/sys/kernel/hostname") + .map(|s| s.trim().to_string()) + .unwrap_or_else(|_| "unknown-host".to_string()); + let threads = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(0); + format!("{host} ({threads} threads)") +} + +/// Unix seconds now, or 0 if the clock is before 1970, which it is not. +pub fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn metric(direction: Direction, budget: Option, recorded: Option) -> Metric { + Metric { + requirement: "NFR-TEST".into(), + what: "a test metric".into(), + unit: "ms".into(), + direction, + machine_sensitive: false, + budget, + recorded, + } + } + + fn cx(same_machine: bool) -> Judging { + Judging { + reference: true, + same_machine, + tolerance: 0.15, + } + } + + #[test] + fn a_budget_is_directional() { + // The bug this exists to prevent: judging a throughput target the way + // a latency target is judged, so a suite that got twice as slow passes + // and one that got twice as fast fails. + let latency = metric(Direction::LowerIsBetter, Some(100.0), None); + assert!(latency.judge(101.0, cx(true)).over_budget); + assert!(!latency.judge(99.0, cx(true)).over_budget); + + let throughput = metric(Direction::HigherIsBetter, Some(100.0), None); + assert!(throughput.judge(99.0, cx(true)).over_budget); + assert!(!throughput.judge(101.0, cx(true)).over_budget); + } + + #[test] + fn drift_is_positive_when_things_got_worse_whichever_way_that_is() { + let latency = metric(Direction::LowerIsBetter, None, Some(100.0)); + assert!(latency.judge(120.0, cx(true)).drift.unwrap() > 0.0); + let throughput = metric(Direction::HigherIsBetter, None, Some(100.0)); + assert!(throughput.judge(80.0, cx(true)).drift.unwrap() > 0.0); + } + + #[test] + fn drift_against_another_machine_is_reported_but_never_a_failure() { + // CI is not the reference desktop. Its numbers are worth printing and + // are not a verdict on anybody's commit. + let m = metric(Direction::LowerIsBetter, None, Some(100.0)); + let elsewhere = m.judge(400.0, cx(false)); + assert!(elsewhere.drift.unwrap() > 0.15); + assert!(!elsewhere.regressed); + assert!(m.judge(400.0, cx(true)).regressed); + } + + #[test] + fn an_unrecorded_metric_cannot_regress() { + // The state the committed file ships in. It must gate on the budget + // and stay silent about drift, rather than treating null as zero and + // declaring an infinite regression. + let m = metric(Direction::LowerIsBetter, Some(100.0), None); + let j = m.judge(50.0, cx(true)); + assert!(j.drift.is_none()); + assert!(!j.regressed); + assert!(!j.over_budget); + } + + #[test] + fn a_machine_sensitive_budget_defers_off_the_reference_desktop() { + let mut m = metric(Direction::HigherIsBetter, Some(100.0), None); + m.machine_sensitive = true; + let on_ci = Judging { + reference: false, + same_machine: false, + tolerance: 0.15, + }; + let j = m.judge(10.0, on_ci); + // A two-core runner must not fail a target written for twenty-four. + assert!(!j.over_budget); + // And the report has to say the budget was not applied, rather than + // letting a deferred budget read as a passed one. + assert!(j.budget_deferred); + // On the reference desktop the same figure is judged. + assert!(m.judge(10.0, cx(false)).over_budget); + } +} diff --git a/tools/bench/src/catalog_open.rs b/tools/bench/src/catalog_open.rs new file mode 100644 index 0000000..791cc53 --- /dev/null +++ b/tools/bench/src/catalog_open.rs @@ -0,0 +1,178 @@ +//! TRACES: NFR-P1 +//! Opening a fifty-thousand-image catalog, and what that actually involves. +//! +//! NFR-P1 says under two seconds on the reference desktop. Until this file +//! existed the number had never been measured, which made it a wish — and +//! `dr-catalog`'s `lib.rs` has carried an `NFR-P1` tag the whole time on code +//! that describes the target rather than checking it. This is the check. +//! +//! # What counts as "open" +//! +//! Not `Catalog::open` alone. That call returns before anything is on screen, +//! and a user's "the catalog opened" is the moment the grid has cells in it. +//! So the measured span is the four things the library view cannot paint +//! without: +//! +//! 1. [`Catalog::open`] — connect, migrate if needed, and **backfill**. The +//! backfill is the interesting one: `schema::backfill` runs on every open +//! and is three passes over the images table, so it is O(library) work on a +//! path whose budget is stated in absolute seconds. +//! 2. [`Catalog::count`] — the total, which is what sizes the scrollbar. +//! 3. [`Catalog::window`] — the first screenful of rows. +//! 4. [`Catalog::timeline`] — the scrubber's buckets, drawn beside the grid +//! from the first frame. +//! +//! `open` alone is reported separately anyway, because if the two ever diverge +//! sharply the fix is in a different place. +//! +//! # What this does *not* measure, said out loud +//! +//! `ui/dr-ui/src/library.rs` does not call [`Catalog::count`] or +//! [`Catalog::window`]. It issues its own SQL — `total_images_scoped`, +//! `total_images_filtered` and friends — against the same tables, with a +//! `VISIBLE` predicate and a burst-folding clause that this crate cannot see +//! without depending on the UI, which would drag Slint into a benchmark job +//! that has no display. So the number here is the **catalog crate's** open +//! path, and the application's is that plus whatever those queries cost. +//! +//! That gap is a real limit on what this file can certify, and it has a +//! falsifiable end: when the grid's queries move down into `dr-catalog` — +//! which is where SQL over catalog tables belongs — this measurement becomes +//! the whole of the application's open, and the caveat can be deleted rather +//! than argued about. +//! +//! # Cold and warm +//! +//! Both are reported. The first open in a process pays for SQLite's page cache +//! being empty and for the schema being read; the second pays for neither, and +//! is what a user gets when they close and reopen a library in the same +//! session. §4.1 asks the question directly for NFR-P8 and it is worth having +//! the answer here too. Neither figure is a genuinely cold *disk*: the fixture +//! was written by this same suite or by an earlier run of it, so the file is +//! in the OS page cache. On the reference desktop's NVMe a truly cold read of +//! a ~14 MB file is a few tens of milliseconds; on a spinning disk it is not. + +use std::path::Path; +use std::time::Instant; + +use anyhow::Result; +use dr_catalog::{Catalog, Granularity, Query}; +use dr_types::Selector; + +use crate::stats::{ms, Percentiles, Rng}; + +/// Rows fetched for the first screenful. +/// +/// A dense grid on a 4K display is around three hundred cells; four hundred is +/// that plus the prefetch margin R2 asks for. Not the whole library, because +/// FR-CAT-4 is explicit that memory must not scale with it — a benchmark that +/// asked for 50,000 rows would be measuring the requirement's violation. +const WINDOW: usize = 400; + +/// The clock the query compiler is handed. +/// +/// Fixed rather than read from the system, so a rolling date filter would +/// compile to the same SQL on every run. The unfiltered query does not consult +/// it at all; this is here so that adding a dated row later does not silently +/// make the suite time-dependent. +const NOW: i64 = 2_000_000_000; + +/// What one measured open produced. +pub struct Open { + /// Open, count, first window, timeline — the whole span, first time. + pub cold_ms: f64, + /// The same four calls on a second connection in the same process. + pub warm_ms: f64, + /// [`Catalog::open`] on its own, out of the cold span. + pub open_only_ms: f64, + /// How many images the count found. Reported so a fixture that failed to + /// populate cannot masquerade as a very fast open. + pub images: usize, + /// Timeline buckets at monthly granularity. + pub buckets: usize, + /// One window fetched at a random offset — the scroll, minus the drawing. + pub window_ms: Percentiles, + /// Count plus first window under a rating filter, which compiles to a + /// correlated subquery over `versions` (see `query::default_version_scalar`). + pub filtered_ms: f64, +} + +/// Measure an open of the catalog at `path`, then `windows` random windows. +pub fn measure(path: &Path, windows: usize) -> Result { + let q = Query::default(); + + let started = Instant::now(); + let catalog = open(path)?; + let open_only_ms = ms(started.elapsed()); + let images = catalog.count(&q, NOW)?; + let rows = catalog.window(&q, 0..WINDOW, NOW)?; + let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len(); + let cold_ms = ms(started.elapsed()); + + // A catalog that returned nothing would post an excellent time. Checked + // rather than trusted, because the failure mode is a *fast* wrong answer. + anyhow::ensure!( + !rows.is_empty() && images > 0 && buckets > 0, + "the fixture catalog answered with {images} images, {} rows and {buckets} buckets — \ + the measurement below would be meaningless", + rows.len() + ); + drop(catalog); + + let started = Instant::now(); + let catalog = open(path)?; + let _ = catalog.count(&q, NOW)?; + let _ = catalog.window(&q, 0..WINDOW, NOW)?; + let _ = catalog.timeline(&q, Granularity::Month, NOW)?; + let warm_ms = ms(started.elapsed()); + + // The scroll. Offsets are drawn from a fixed seed rather than swept in + // order, because a sequential sweep would be answered increasingly out of + // SQLite's own cache and would flatter the deep end of the library — which + // is exactly the end a person reaches by dragging the scrollbar. + let mut rng = Rng::new(0x5C_20_11); + let span = images.saturating_sub(WINDOW).max(1) as u64; + // Discarded: the first window of a new connection compiles the statement + // and faults in the b-tree's upper levels, and neither recurs while + // scrolling. + for _ in 0..4 { + let start = rng.below(span) as usize; + let _ = catalog.window(&q, start..start + WINDOW, NOW)?; + } + let mut samples = Vec::with_capacity(windows); + for _ in 0..windows { + let start = rng.below(span) as usize; + let t = Instant::now(); + let rows = catalog.window(&q, start..start + WINDOW, NOW)?; + samples.push(ms(t.elapsed())); + debug_assert!(!rows.is_empty()); + } + + // A filter that has to reach the default version for every candidate row. + // Cheap to add and the one query shape in the grid that is not a scan of + // `images` alone, so a regression in it would otherwise show up first as a + // user complaint. + let rated = Query { + filter: Selector::Rating { min: 2 }, + ..Query::default() + }; + let t = Instant::now(); + let _ = catalog.count(&rated, NOW)?; + let _ = catalog.window(&rated, 0..WINDOW, NOW)?; + let filtered_ms = ms(t.elapsed()); + + Ok(Open { + cold_ms, + warm_ms, + open_only_ms, + images, + buckets, + window_ms: Percentiles::of(samples), + filtered_ms, + }) +} + +fn open(path: &Path) -> Result { + Catalog::open(path) + .map_err(|e| anyhow::anyhow!("opening the fixture catalog at {}: {e}", path.display())) +} diff --git a/tools/bench/src/exporting.rs b/tools/bench/src/exporting.rs new file mode 100644 index 0000000..16dcc6e --- /dev/null +++ b/tools/bench/src/exporting.rs @@ -0,0 +1,139 @@ +//! The half of a 24 MP export that needs no GPU. +//! +//! # This cannot certify NFR-P7, and is not tagged as though it could +//! +//! NFR-P7 is "full-resolution export (24 MP, **full chain**) < 2 s". The full +//! chain is decode, demosaic, a GPU render at full resolution, a read-back, +//! and then everything `dr-export` does — resize, output sharpening, encode. +//! Only the last three of those run without an adapter, and the CI runner has +//! none. So what is measured here is the encode half, and no requirement tag +//! anywhere in this crate names NFR-P7. +//! +//! (Written without the tag's own spelling on purpose. `tools/traceability` +//! matches the marker anywhere on a line and parses the identifier after it, so +//! a sentence saying "there is no tag for NFR-P7" would *be* a tag for NFR-P7 — +//! a disclaimer that made itself false.) +//! +//! That is a deliberate refusal rather than an oversight. `CONTRIBUTING.md` +//! asks that a requirement be closed by a test that would fail if the +//! behaviour were removed, and `docs/code-health.md` CH-4 records what the +//! coverage figure looks like when tags are hung on plumbing instead. A tag +//! here would say the export budget is checked; the GPU half of it would still +//! be unchecked. +//! +//! # What the number is still good for +//! +//! It is a **one-sided** gate, and that is worth having. The encode half is a +//! lower bound on the whole: if resizing, sharpening and encoding 24 MP alone +//! take longer than two seconds, NFR-P7 is violated no matter how fast the +//! render is. So the budget in `docs/bench-baseline.json` is the requirement's +//! own 2000 ms, and exceeding it fails the build honestly. Coming in under it +//! proves nothing about the requirement, and the report says so rather than +//! printing a tick. +//! +//! # Two rows +//! +//! `Original` is the one the budget is judged on: it is the archival export, +//! the largest encode, and the case FR-EXP-9 is about. `LongEdge(2048)` is the +//! ordinary web export, where the resample does real work and the encode does +//! very little — it is reported because a regression in `size::resample` would +//! be invisible in the first row, where source and target dimensions are equal. + +use std::time::Instant; + +use anyhow::Result; +use dr_export::{export, Frame}; +use dr_types::{ExportFormat, ExportSettings, OutputSharpening, SizingMode}; + +use crate::fixture::plausible_frame; +use crate::stats::{ms, Percentiles}; + +/// The frame every row exports. +/// +/// 6000 × 4000 is 24.0 MP — a full-frame body, and the exact figure NFR-P7 +/// names. As RGBA8 it is 96 MB, and the export path holds a resized copy and a +/// sharpened copy alongside it, so a run needs roughly 300 MB of headroom. +/// Worth knowing before a small runner reports this as a mysterious kill. +pub const SOURCE: (u32, u32) = (6000, 4000); + +/// Measured exports per row. Not a hundred: one 24 MP encode is most of a +/// second, and a hundred of them would be a two-minute CI step to establish +/// what five establish. Nearest-rank p99 of five is the worst of the five, +/// which for a row this expensive is the honest reading anyway. +const RUNS: usize = 5; + +/// A row of the export table. +pub struct EncodeRun { + /// The name this row carries in `docs/bench-baseline.json`. + pub key: &'static str, + pub label: &'static str, + pub width: u32, + pub height: u32, + /// Encoded file size, so a row that silently stopped compressing is + /// visible as well as a row that got slow. + pub bytes: usize, + pub times: Percentiles, +} + +/// Export the same 24 MP frame at each sizing, timing `dr_export::export`. +pub fn measure() -> Result> { + let (w, h) = SOURCE; + // Detail at every scale, for the same reason the thumbnail fixture has it: + // a flat frame compresses to almost nothing and would make the encoder + // look several times faster than any photograph makes it. + let frame = Frame::new(w, h, plausible_frame(w, h, 0)) + .map_err(|e| anyhow::anyhow!("building the 24 MP bench frame: {e}"))?; + + let sizings: [(&'static str, &'static str, SizingMode); 2] = [ + ("export_24mp_original_ms", "original", SizingMode::Original), + ( + "export_24mp_long_edge_2048_ms", + "long edge 2048", + SizingMode::LongEdge(2048), + ), + ]; + + let mut rows = Vec::with_capacity(sizings.len()); + for (key, label, sizing) in sizings { + let settings = ExportSettings { + format: ExportFormat::Jpeg, + // 90 is the default and what a photographer would not need to + // change; quality moves encode time, so it belongs in the record. + quality: 90, + sizing, + sharpening: OutputSharpening::Screen, + ..Default::default() + }; + + // Discarded. The first export of a process grows the allocator to hold + // three 24 MP buffers, which is a cost paid once and not per file in + // the batch export FR-EXP-7 describes. + let warm = run_once(&frame, &settings)?; + + let mut samples = Vec::with_capacity(RUNS); + let mut last = warm; + for _ in 0..RUNS { + let started = Instant::now(); + last = run_once(&frame, &settings)?; + samples.push(ms(started.elapsed())); + } + + rows.push(EncodeRun { + key, + label, + width: last.0, + height: last.1, + bytes: last.2, + times: Percentiles::of(samples), + }); + } + + Ok(rows) +} + +/// One export, returning what it produced rather than the pixels. +fn run_once(frame: &Frame, settings: &ExportSettings) -> Result<(u32, u32, usize)> { + let encoded = export(frame, settings, "bench.jpg".to_string(), None) + .map_err(|e| anyhow::anyhow!("exporting the bench frame: {e}"))?; + Ok((encoded.width, encoded.height, encoded.bytes.len())) +} diff --git a/tools/bench/src/fixture.rs b/tools/bench/src/fixture.rs new file mode 100644 index 0000000..fa292d3 --- /dev/null +++ b/tools/bench/src/fixture.rs @@ -0,0 +1,448 @@ +//! The synthetic 50k catalog, and the handful of real files it points at. +//! +//! `docs/requirements.md` §8 asks for "an automated benchmark suite against a +//! synthetic 50k catalog". The hard part of that sentence is *50k*: a real +//! library of that size is several terabytes and cannot live in a repository, +//! in a CI cache, or on a laptop that also has to compile the thing. +//! +//! # The trick, and what it costs +//! +//! Rows are cheap and pixels are not. So this builds **fifty thousand catalog +//! rows** over a **pool of a dozen real image files**, each referenced by +//! several thousand of them. Everything the catalog half of the suite measures +//! — opening, counting, windowing, bucketing a timeline — touches only rows, +//! and is therefore exact. Everything the pixel half measures — decode, +//! downscale, orient, encode — touches one file at a time and does not care +//! how many rows point at it. The fixture is ~14 MB on disk instead of ~2 TB +//! and neither half is flattered by that. +//! +//! What it *does* cost is stated rather than hidden: the file pool is small +//! enough to sit in the OS page cache, so [`crate::thumbnails`] measures CPU +//! throughput with the read already paid for. That is the right thing to +//! measure for NFR-P3 — the target is written about the embedded preview path, +//! not about a disk — but it is not a claim about a cold library on spinning +//! rust, and the harness does not make one. +//! +//! # Reproducible from a seed +//! +//! Every value comes from [`Rng`], seeded once. Two machines running the same +//! seed build byte-comparable catalogs, which is the property that lets a +//! number measured on the reference desktop be compared with a number measured +//! anywhere else. [`Stamp`] records what a directory was built from, so a +//! fixture is reused when it matches and rebuilt when it does not — including +//! when `dr-catalog`'s schema version moves, since a catalog built by an older +//! build would otherwise be measured through a migration that a user's would +//! not run. +//! +//! # The sources are generated, not committed +//! +//! No photograph in this repository is licensed for redistribution, and a +//! dozen camera previews would be megabytes of binary in git for ever. So the +//! pool is synthesised: a coarse gradient with a fine dither on top, which is +//! the same shape `core/dr-gpu/examples/frame_budget.rs` synthesises its source +//! from and for the same reason. A flat frame lets the memory system serve +//! every sample from one cache line, which flatters a box filter by an amount +//! that has nothing to do with photographs; pure noise defeats the JPEG +//! encoder's entropy coder in the other direction and would make the encode +//! half of a thumbnail look worse than any real image ever does. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use dr_catalog::Catalog; +use serde::{Deserialize, Serialize}; + +use crate::stats::Rng; + +/// How many distinct image files the pool holds. +/// +/// Twelve rather than one, so that a decode measured over a batch is not one +/// file's quirks repeated — a single frame that happened to compress unusually +/// well would set the whole number — and rather than fifty thousand, so the +/// fixture stays a directory a person can look at. +pub const SOURCE_POOL: usize = 12; + +/// The size of one pooled file, in pixels. +/// +/// 1620×1080 is not a round number: it is what `core/dr-decode/src/preview.rs` +/// records a Canon CR2 carrying in IFD2, and the embedded preview is what +/// NFR-P3 names. A camera JPEG is 24 MP and a camera *preview* is about this, +/// so measuring the preview path against a 24 MP file would measure something +/// the sweep never does. +pub const PREVIEW: (u32, u32) = (1620, 1080); + +/// Folders the rows are spread across. +/// +/// Spread evenly and without regard to capture date, because what a folder +/// count decides is the cost of the folder filter's `IN (SELECT …)` and the +/// size of the `folders` table — not which image is in which. +const FOLDERS: usize = 400; + +/// The earliest capture time in the fixture: 13 December 2015, UTC. +/// +/// Fixed rather than relative to the clock. A library whose dates moved with +/// the calendar would make `timeline` bucket differently from one month to the +/// next, and a benchmark that measures a different query each time it runs is +/// not measuring a regression. +const EPOCH: i64 = 1_450_000_000; + +/// The span capture times are drawn from: twelve years. +/// +/// Long enough that the timeline query has real structure to bucket — at +/// monthly granularity that is ~144 buckets, which is the shape the scrubber +/// actually draws — and not so long that a year holds too few frames to look +/// like a library. +const SPAN: i64 = 12 * 365 * 86_400; + +/// Bodies and lenses, for the columns the camera and lens filters read. +const CAMERAS: [&str; 6] = [ + "Canon EOS R5", + "Nikon Z 7II", + "Sony ILCE-7RM5", + "Fujifilm X-T5", + "Panasonic DC-S5M2", + "OM SYSTEM OM-1", +]; + +const LENSES: [&str; 6] = [ + "RF24-70mm F2.8 L IS USM", + "NIKKOR Z 50mm f/1.8 S", + "FE 85mm F1.4 GM", + "XF16-55mmF2.8 R LM WR", + "LUMIX S 20-60mm F3.5-5.6", + "M.Zuiko Digital ED 12-40mm F2.8", +]; + +/// What a fixture directory was built from. +/// +/// Written beside the catalog and compared on every run. A mismatch rebuilds: +/// silently reusing a fixture built from a different seed, a different row +/// count or an older schema would compare two numbers that describe two +/// different workloads, which is worse than having no number at all. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Stamp { + /// Bumped by hand whenever anything in this file changes what gets built. + /// The seed cannot carry that: the same seed through different generation + /// code produces a different library. + pub generator: u32, + pub seed: u64, + pub images: usize, + pub sources: usize, + pub folders: usize, + pub preview_width: u32, + pub preview_height: u32, + /// `dr-catalog`'s schema version at build time. + pub schema_version: i64, +} + +/// Bump on any change to what [`build`] writes. +const GENERATOR: u32 = 1; + +/// A built fixture on disk. +pub struct Fixture { + pub dir: PathBuf, + pub catalog: PathBuf, + pub thumbs: PathBuf, + pub sources: Vec, + pub stamp: Stamp, + /// The catalog file's size, reported because it is the thing an open has + /// to read and because it is the honest denominator for "is 2 s a lot". + pub catalog_bytes: u64, +} + +/// Where a fixture lives by default. +/// +/// The temporary directory rather than `target/`, for two reasons. It survives +/// `cargo clean`, so a fixture is built once per machine rather than once per +/// clean; and it is not inside anything CI caches, so a 14 MB catalog is not +/// uploaded and downloaded on every push to save the two seconds it takes to +/// generate. `DR_BENCH_DIR` overrides it. +pub fn default_dir() -> PathBuf { + match std::env::var_os("DR_BENCH_DIR") { + Some(dir) => PathBuf::from(dir), + None => std::env::temp_dir().join("darkroom-bench"), + } +} + +/// Build the fixture under `dir`, or confirm the one already there. +/// +/// Returns whether it had to be built, so the caller can say so: a run that +/// includes fixture generation has a warm page cache for the catalog file it +/// is about to open, and a reader comparing two numbers deserves to know which +/// of them was measured that way. +pub fn build(dir: &Path, seed: u64, images: usize) -> Result<(Fixture, bool)> { + let stamp = Stamp { + generator: GENERATOR, + seed, + images, + sources: SOURCE_POOL, + folders: FOLDERS, + preview_width: PREVIEW.0, + preview_height: PREVIEW.1, + schema_version: dr_catalog::schema::SCHEMA_VERSION, + }; + + let catalog = dir.join("catalog.sqlite"); + let stamp_path = dir.join("stamp.json"); + let sources: Vec = (0..SOURCE_POOL) + .map(|i| dir.join("sources").join(format!("preview-{i:02}.jpg"))) + .collect(); + + let usable = matches_stamp(&stamp_path, &stamp) + && catalog.is_file() + && sources.iter().all(|p| p.is_file()); + + if !usable { + std::fs::create_dir_all(dir.join("sources")) + .with_context(|| format!("creating the fixture directory {}", dir.display()))?; + // The stamp goes last. A build interrupted halfway leaves no stamp, so + // the next run rebuilds rather than measuring a truncated catalog. + let _ = std::fs::remove_file(&stamp_path); + write_sources(&sources, seed)?; + write_catalog(&catalog, seed, images)?; + std::fs::write(&stamp_path, serde_json::to_vec_pretty(&stamp)?) + .with_context(|| format!("writing {}", stamp_path.display()))?; + } + + let catalog_bytes = std::fs::metadata(&catalog) + .with_context(|| format!("stat {}", catalog.display()))? + .len(); + + Ok(( + Fixture { + dir: dir.to_path_buf(), + catalog, + thumbs: dir.join("thumbs"), + sources, + stamp, + catalog_bytes, + }, + !usable, + )) +} + +fn matches_stamp(path: &Path, want: &Stamp) -> bool { + let Ok(text) = std::fs::read_to_string(path) else { + return false; + }; + matches!(serde_json::from_str::(&text), Ok(have) if have == *want) +} + +// --------------------------------------------------------------------------- +// The file pool +// --------------------------------------------------------------------------- + +/// Write the pool of JPEGs the pixel half decodes. +/// +/// Encoded through [`dr_thumbs::encode_rgba`] rather than a second encoder +/// call of this crate's own. That is the quality the store already uses (82), +/// which is a little below what a camera writes its previews at, and it is one +/// fewer place for an encoder setting to drift. Stated because it is visible +/// in the result: a slightly softer source decodes marginally faster than a +/// camera's own preview would. +fn write_sources(paths: &[PathBuf], seed: u64) -> Result<()> { + let (w, h) = PREVIEW; + for (i, path) in paths.iter().enumerate() { + let rgba = plausible_frame(w, h, seed ^ (i as u64)); + let jpeg = dr_thumbs::encode_rgba(w, h, &rgba) + .map_err(|e| anyhow::anyhow!("encoding the fixture source {}: {e}", path.display()))?; + std::fs::write(path, jpeg).with_context(|| format!("writing {}", path.display()))?; + } + Ok(()) +} + +/// RGBA with detail at every scale: a coarse gradient plus a fine dither. +/// +/// See this module's header for why neither a flat frame nor pure noise would +/// do. `salt` moves the gradient and the dither together so the twelve files +/// differ from one another rather than being twelve copies with different +/// names — a JPEG encoder that saw the same image twelve times would have the +/// same cache behaviour every time, which a library does not. +pub fn plausible_frame(w: u32, h: u32, salt: u64) -> Vec { + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; + let bias = (salt % 97) as u32; + for y in 0..h as usize { + let row = y * (w as usize) * 4; + for x in 0..w as usize { + // A cheap integer hash, so neighbouring pixels differ and the + // encoder has real high-frequency content to spend bits on. + let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13; + let dither = (n & 0x1f) as u32; + let gx = (x * 200 / (w as usize).max(1)) as u32; + let gy = (y * 55 / (h as usize).max(1)) as u32; + let px = &mut rgba[row + x * 4..row + x * 4 + 4]; + px[0] = (30 + bias + gx + dither).min(255) as u8; + px[1] = (40 + gy + dither).min(255) as u8; + px[2] = (60 + gx / 2 + gy + dither).min(255) as u8; + px[3] = 255; + } + } + rgba +} + +// --------------------------------------------------------------------------- +// The catalog +// --------------------------------------------------------------------------- + +/// Write a catalog holding `images` rows, plus a default version for each. +/// +/// The default versions are not decoration. `Catalog::open` backfills them for +/// any image that lacks one (see `schema::backfill`), so a fixture without +/// them would charge every measured open for fifty thousand inserts once and +/// nothing thereafter — a first number that bore no relation to the second, +/// and a benchmark whose result depended on whether it had been run before. +fn write_catalog(path: &Path, seed: u64, images: usize) -> Result<()> { + for suffix in ["", "-wal", "-shm"] { + let mut p = path.as_os_str().to_os_string(); + p.push(suffix); + let _ = std::fs::remove_file(PathBuf::from(p)); + } + + let catalog = Catalog::open(path) + .map_err(|e| anyhow::anyhow!("creating the fixture catalog at {}: {e}", path.display()))?; + let conn = catalog.connection(); + let mut rng = Rng::new(seed); + + conn.execute_batch("BEGIN")?; + + conn.execute( + "INSERT INTO roots(id, kind, label, last_seen, scan_generation) + VALUES (1, 'local', '/library', ?1, 1)", + rusqlite::params![EPOCH], + )?; + + { + let mut folder = conn.prepare( + "INSERT INTO folders(id, root_id, parent_id, path, mtime, entry_count, + scanned_generation) + VALUES (?1, 1, NULL, ?2, ?3, ?4, 1)", + )?; + for f in 0..FOLDERS { + let id = f as i64 + 1; + let folder_path = format!("/library/{:04}/{:02}", 2016 + f / 12, f % 12 + 1); + let mtime = EPOCH + f as i64 * 86_400; + let entries = (images / FOLDERS.max(1)) as i64; + folder.execute(rusqlite::params![id, folder_path, mtime, entries])?; + } + } + + { + let mut image = conn.prepare( + "INSERT INTO images(id, root_id, folder_id, source_ref, format, w, h, + captured_at, captured_offset, camera, lens, iso, + aperture, shutter, availability, file_size, + file_mtime, metadata_state, added_at) + VALUES (?1, 1, ?2, ?3, 'CR3', ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, + ?13, ?14, ?15, ?16, ?17)", + )?; + let mut version = conn.prepare( + "INSERT INTO versions(id, image_id, uuid, name, is_default, rating, + label, flag) + VALUES (?1, ?1, ?2, 'Original', 1, ?3, ?4, ?5)", + )?; + + // Every value is bound to a local before it reaches `params!`. Not + // style: the macro takes a reference to each argument, and an + // expression like `TABLE[rng.below(n) as usize]` inside it borrows an + // element of a temporary array while `rng` is also being borrowed + // mutably. Locals make the evaluation order and the lifetimes obvious. + const OFFSETS: [i64; 5] = [0, 60, 120, -300, 540]; + const ISOS: [i64; 7] = [100, 200, 400, 800, 1600, 3200, 6400]; + const APERTURES: [f64; 6] = [1.4, 1.8, 2.8, 4.0, 5.6, 8.0]; + const SHUTTERS: [f64; 6] = [0.004, 0.008, 0.0167, 0.005, 0.002, 0.5]; + // Mostly metadata-only, as a large library on a laptop is: some + // previewed, a few with the original present. + const AVAILABILITY: [i64; 6] = [0, 0, 0, 1, 1, 2]; + + for i in 0..images { + let id = i as i64 + 1; + let bucket = i % FOLDERS; + let folder = bucket as i64 + 1; + let source_ref = format!( + "/library/{:04}/{:02}/IMG_{id:05}.CR3", + 2016 + bucket / 12, + bucket % 12 + 1 + ); + let captured = EPOCH + rng.below(SPAN as u64) as i64; + // A quarter of the library shot in portrait, which is what makes + // the thumbnail path's orientation permutation a real cost rather + // than a branch that is never taken. + let (w, h) = if i % 4 == 3 { + (4000i64, 6000i64) + } else { + (6000i64, 4000i64) + }; + // Two per cent still awaiting full EXIF — a library is never + // entirely finished being read, and the grid has to render that + // state (`metadata_state` 1). + let state: i64 = if rng.below(50) == 0 { 1 } else { 2 }; + let camera = CAMERAS[rng.below(CAMERAS.len() as u64) as usize]; + let lens = LENSES[rng.below(LENSES.len() as u64) as usize]; + // Minutes east of UTC: a library shot in a handful of places. + let offset = OFFSETS[rng.below(OFFSETS.len() as u64) as usize]; + let iso = ISOS[rng.below(ISOS.len() as u64) as usize]; + let aperture = APERTURES[rng.below(APERTURES.len() as u64) as usize]; + let shutter = SHUTTERS[rng.below(SHUTTERS.len() as u64) as usize]; + let availability = AVAILABILITY[rng.below(AVAILABILITY.len() as u64) as usize]; + let file_size = 20_000_000i64 + rng.below(30_000_000) as i64; + let file_mtime = captured + 60; + let added_at = captured + 3600; + + image.execute(rusqlite::params![ + id, + folder, + source_ref, + w, + h, + captured, + offset, + camera, + lens, + iso, + aperture, + shutter, + availability, + file_size, + file_mtime, + state, + added_at, + ])?; + + // Ratings skewed the way a culled library is: most unrated, a few + // picks, fewer still at five stars. + let rating: i64 = match rng.below(100) { + 0..=69 => 0, + 70..=84 => 1, + 85..=93 => 2, + 94..=97 => 3, + 98 => 4, + _ => 5, + }; + let flag: i64 = match rng.below(100) { + 0..=79 => 0, + 80..=94 => 1, + _ => 2, + }; + let label: Option = match rng.below(100) { + 0..=89 => None, + n => Some((n % 5) as i64 + 1), + }; + let a = rng.next_u64(); + let b = rng.next_u64(); + let uuid = format!("{a:016x}{b:016x}"); + version.execute(rusqlite::params![id, uuid, rating, label, flag])?; + } + } + + conn.execute_batch("COMMIT")?; + + // Deliberately no `ANALYZE`. The application never runs one, so a fixture + // that did would be measuring a query plan no user's catalog gets — and a + // plan chosen from statistics is exactly the sort of thing that would make + // the benchmark faster than the product. + + // Dropping the connection checkpoints the WAL, so the file the next open + // reads is the whole catalog rather than a stub plus a journal. + drop(catalog); + Ok(()) +} diff --git a/tools/bench/src/main.rs b/tools/bench/src/main.rs new file mode 100644 index 0000000..48f13da --- /dev/null +++ b/tools/bench/src/main.rs @@ -0,0 +1,540 @@ +//! The benchmark suite `docs/requirements.md` §8 has been promising. +//! +//! §8 says performance is verified by *"an automated benchmark suite against a +//! synthetic 50k catalog, run per-commit … A regression beyond stated +//! tolerance fails the build."* Until this crate there was none: no `benches/`, +//! no `[[bench]]`, no criterion, no fixture. Ten performance requirements could +//! therefore be neither passed nor failed, and five of them carried a +//! requirement tag anyway. +//! +//! ```sh +//! cargo run --release -p dr-bench -- check # measure and gate +//! cargo run --release -p dr-bench -- check --reference # …on the reference desktop +//! cargo run --release -p dr-bench -- record --reference # rewrite the baseline +//! ``` +//! +//! **Release, always.** The workspace builds its own crates at `opt-level = 0` +//! in dev (see the root `Cargo.toml`), and every number here is dominated by +//! this workspace's own code — the JPEG decode, the box filter, the resample, +//! the sharpen. A debug run measures rustc's shadow, exactly as +//! `core/dr-gpu/examples/frame_budget.rs` warns for the same reason. The +//! harness says so at the top of every report rather than trusting anyone to +//! remember. +//! +//! # What this covers, and what it deliberately does not +//! +//! Covered, with a gate: +//! +//! - **NFR-P1** and R2's catalog clause — opening a 50k catalog and painting +//! the first grid ([`catalog_open`]). +//! - **NFR-P3** — thumbnail throughput on the embedded preview path +//! ([`thumbnails`]). +//! +//! Measured, reported, and honestly *not* tagged, because only part of the +//! requirement is in reach without a GPU or a running UI: +//! +//! - **NFR-P7** — the encode half of a 24 MP export ([`exporting`]). A +//! one-sided gate: it can fail the requirement, it cannot pass it. +//! - **NFR-P8** — the catalog layer's share of idle RSS ([`memory`]), together +//! with the answer to the question §4.1 asks about GPU memory. +//! +//! Out of scope entirely, and left to say so rather than faked: NFR-P2, P4, +//! P5, P6, P9, P10, P11, P12, P13, P14 and P15. Every one of them needs a +//! frame-timing probe inside a running Slint application, a GPU adapter, or +//! both. The GPU half of the suite that *does* exist is +//! `core/dr-gpu/tests/frame_budget.rs`, which asserts FR-DSP-3 and skips itself +//! where there is no adapter; `.gitea/workflows/benchmark.yml` runs it as its +//! own job for exactly that reason. +//! +//! # Exit codes +//! +//! `0` everything inside its budget and its tolerance; `1` a gate failed; `2` +//! the harness itself could not run. Distinguished because a CI log that says +//! "failed" should not leave anyone guessing whether the code got slower or the +//! fixture would not build. + +mod baseline; +mod catalog_open; +mod exporting; +mod fixture; +mod memory; +mod stats; +mod thumbnails; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; + +use baseline::{Baseline, Judging}; + +/// The seed the committed baseline describes. +/// +/// Changing it invalidates every recorded figure, because it changes the +/// library being measured. That is why it is a constant here and a field in +/// the stamp rather than something a flag quietly varies. +const SEED: u64 = 20_260_829; + +/// Rows in the synthetic library. §8 says 50k; this is that. +const IMAGES: usize = 50_000; + +/// Thumbnails produced for the throughput row. +/// +/// Twelve hundred rather than fifty thousand. At the target rate the whole +/// library is eight minutes of CI, and a rate measured over 1,200 images is +/// the same rate — the sweep has no state that changes after the first chunk, +/// which the per-image percentile alongside it is there to demonstrate. +const THUMBNAILS: usize = 1_200; + +/// Windows fetched for the scroll row. +/// +/// A hundred, so nearest-rank puts the 99th percentile on the second-worst — +/// the same reading `core/dr-gpu/examples/frame_budget.rs` takes of a hundred +/// frames, and the reason it takes it: one bad one in a hundred is one too +/// many, and a single scheduler hiccup on an unrelated process should not +/// decide the verdict on its own. +const WINDOWS: usize = 100; + +fn main() { + env_logger::init(); + match run() { + Ok(true) => {} + Ok(false) => std::process::exit(1), + Err(e) => { + eprintln!("dr-bench: {e:#}"); + std::process::exit(2); + } + } +} + +/// Returns whether every gate passed. +fn run() -> Result { + let mut args = std::env::args().skip(1); + let command = args.next().unwrap_or_else(|| "help".to_string()); + + // The probe takes a path and nothing else — see `memory` for why it is a + // separate process rather than a function call. + if command == "memory-probe" { + let path = args + .next() + .ok_or_else(|| anyhow::anyhow!("memory-probe needs a catalog path"))?; + memory::probe(&PathBuf::from(path))?; + return Ok(true); + } + + let mut reference = false; + let mut fixture_dir = fixture::default_dir(); + let mut baseline_path = Baseline::default_path()?; + let mut lanes = thumbnails::default_lanes(); + let mut thumbnail_count = THUMBNAILS; + + while let Some(flag) = args.next() { + match flag.as_str() { + "--reference" => reference = true, + "--fixture" => fixture_dir = PathBuf::from(expect_value(&mut args, "--fixture")?), + "--baseline" => baseline_path = PathBuf::from(expect_value(&mut args, "--baseline")?), + "--lanes" => lanes = expect_value(&mut args, "--lanes")?.parse()?, + "--thumbnails" => thumbnail_count = expect_value(&mut args, "--thumbnails")?.parse()?, + other => anyhow::bail!("unknown flag {other}; try `dr-bench help`"), + } + } + + match command.as_str() { + "run" => measure_and_report( + &fixture_dir, + &baseline_path, + reference, + lanes, + thumbnail_count, + Mode::Report, + ), + "check" => measure_and_report( + &fixture_dir, + &baseline_path, + reference, + lanes, + thumbnail_count, + Mode::Gate, + ), + "record" => measure_and_report( + &fixture_dir, + &baseline_path, + reference, + lanes, + thumbnail_count, + Mode::Record, + ), + "help" | "--help" | "-h" => { + print_help(); + Ok(true) + } + other => anyhow::bail!("unknown command {other}; try `dr-bench help`"), + } +} + +fn expect_value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .ok_or_else(|| anyhow::anyhow!("{flag} needs a value")) +} + +/// What a run does with what it measured. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Print, judge nothing. + Report, + /// Print and fail the build on a violated budget or a regression. + Gate, + /// Print and rewrite the committed baseline from what was measured. + Record, +} + +fn print_help() { + println!( + "\ +dr-bench — DarkRoom's performance suite (docs/requirements.md §8) + + run measure and print, judging nothing + check measure, print, and exit 1 on a violated budget or a regression + record measure and rewrite docs/bench-baseline.json from the result + +Flags: + --reference this machine is the reference desktop, so machine-sensitive + budgets are asserted rather than reported + --fixture where the synthetic 50k catalog lives (or $DR_BENCH_DIR) + --baseline the committed numbers (default docs/bench-baseline.json) + --lanes sweep lanes for the thumbnail row (default: CPU threads) + --thumbnails images in the thumbnail row (default 1200) + +Build it in release. A debug build measures the compiler, not the pipeline — +see the module documentation and core/dr-gpu/examples/frame_budget.rs." + ); +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +fn measure_and_report( + fixture_dir: &std::path::Path, + baseline_path: &std::path::Path, + reference: bool, + lanes: usize, + thumbnail_count: usize, + mode: Mode, +) -> Result { + let machine = baseline::machine_id(); + println!("DarkRoom benchmark suite — the half that needs no GPU"); + println!("machine {machine}"); + if cfg!(debug_assertions) { + println!( + "profile DEBUG — every figure below is several times worse than the \ + product's. Rerun with --release." + ); + } else { + println!("profile release"); + } + + let (fx, built) = fixture::build(fixture_dir, SEED, IMAGES)?; + println!( + "fixture {} images, {} sources at {}x{}, seed {}, catalog {:.1} MB{}", + fx.stamp.images, + fx.stamp.sources, + fx.stamp.preview_width, + fx.stamp.preview_height, + fx.stamp.seed, + fx.catalog_bytes as f64 / 1e6, + if built { " (built just now)" } else { "" } + ); + println!(" {}", fx.dir.display()); + println!(); + + // Memory first, and in its own process. See `memory` for why: measuring + // RSS after the thumbnail sweep would report the sweep's high-water mark + // wearing the catalog's name. + let rss = match memory::in_a_fresh_process(&fx.catalog) { + Ok(rss) => Some(rss), + Err(e) => { + println!("memory unavailable: {e}"); + None + } + }; + + let open = catalog_open::measure(&fx.catalog, WINDOWS)?; + let thumbs = thumbnails::measure(&fx.sources, &fx.thumbs, thumbnail_count, lanes)?; + let exports = exporting::measure()?; + + print_details(&open, &thumbs, &exports, rss); + + let values = collect(&open, &thumbs, &exports, rss); + let mut base = Baseline::load(baseline_path)?; + + if mode == Mode::Record { + if !reference { + println!( + "note recording from a machine that has not declared itself the \ + reference desktop.\n §8 records the reference desktop's numbers; \ + this overwrites them." + ); + } + for (key, value) in &values { + if let Some(metric) = base.metrics.get_mut(key) { + metric.recorded = Some(*value); + } + } + base.recorded_on = Some(machine); + base.recorded_at_unix = Some(baseline::now_unix()); + base.fixture = Some(fx.stamp.clone()); + base.save(baseline_path)?; + println!("\nrecorded {}", baseline_path.display()); + return Ok(true); + } + + let same_machine = base.recorded_on.as_deref() == Some(machine.as_str()); + let comparable = base.fixture.as_ref().is_some_and(|f| *f == fx.stamp); + let cx = Judging { + reference, + same_machine: same_machine && comparable, + tolerance: base.tolerance, + }; + let failures = print_verdict(&base, &values, cx, &machine, comparable); + + if failures.is_empty() { + return Ok(true); + } + println!(); + for line in &failures { + println!("FAIL {line}"); + } + println!(); + println!( + " {} gate(s) failed. docs/benchmarks.md says what each metric measures and\n \ + docs/bench-baseline.json holds the numbers these used to be.", + failures.len() + ); + Ok(mode != Mode::Gate) +} + +/// The metric keys, and the measurement each one reads. +/// +/// One place, so the report, the gate and the recorded file cannot disagree +/// about what `catalog_open_ms` means. +fn collect( + open: &catalog_open::Open, + thumbs: &thumbnails::Throughput, + exports: &[exporting::EncodeRun], + rss: Option, +) -> BTreeMap { + let mut v = BTreeMap::new(); + v.insert("catalog_open_ms".to_string(), open.cold_ms); + v.insert("catalog_open_warm_ms".to_string(), open.warm_ms); + v.insert("catalog_window_p99_ms".to_string(), open.window_ms.p99); + v.insert("catalog_filtered_ms".to_string(), open.filtered_ms); + let ips = thumbs.images_per_second; + v.insert("thumbnail_throughput_ips".to_string(), ips); + v.insert( + "thumbnail_per_image_p99_ms".to_string(), + thumbs.per_image.p99, + ); + for row in exports { + v.insert(row.key.to_string(), row.times.p99); + } + if let Some(rss) = rss { + v.insert("catalog_idle_rss_mb".to_string(), rss.now_mb()); + } + v +} + +// --------------------------------------------------------------------------- +// The report +// --------------------------------------------------------------------------- + +fn print_details( + open: &catalog_open::Open, + thumbs: &thumbnails::Throughput, + exports: &[exporting::EncodeRun], + rss: Option, +) { + println!("Opening the catalog (NFR-P1, and R2's second sentence)"); + println!( + " {:>10.1} ms open, count, first window and timeline — cold, first \ + connection of the process", + open.cold_ms + ); + println!( + " {:>10.1} ms the same four calls on a second connection", + open.warm_ms + ); + println!( + " {:>10.1} ms Catalog::open alone (connect, migrate, backfill)", + open.open_only_ms + ); + println!( + " {:>10.1} ms count and first window under a rating filter", + open.filtered_ms + ); + println!( + " {:>10.2} ms one 400-row window at a random offset, p99 of {WINDOWS} \ + (p50 {:.2}, max {:.2})", + open.window_ms.p99, open.window_ms.p50, open.window_ms.max + ); + println!( + " {} images, {} monthly timeline buckets", + open.images, open.buckets + ); + println!(); + + println!("Thumbnails on the embedded preview path (NFR-P3: >= 100 img/s)"); + println!( + " {:>10.1} img/s over {} images on {} lanes, {:.2} s of wall clock", + thumbs.images_per_second, + thumbs.images, + thumbs.lanes, + thumbs.wall_ms / 1e3 + ); + println!( + " {:>10.2} ms per image on its lane, p99 (p50 {:.2}, max {:.2})", + thumbs.per_image.p99, thumbs.per_image.p50, thumbs.per_image.max + ); + println!( + " {} stored, {} failed", + thumbs.stored, thumbs.failed + ); + println!(); + println!(" The bytes are in memory before the clock starts, so this is CPU"); + println!(" throughput with the fetch already paid for. That is what the target's"); + println!(" \"embedded preview path\" names; it is not a claim about a remote library."); + println!(); + + println!("Exporting 24 MP — the encode half only (NFR-P7 is the whole chain)"); + println!( + " {:>14} {:>11} {:>9} {:>9} {:>9}", + "sizing", "output", "p50", "p99", "file" + ); + for row in exports { + println!( + " {:>14} {:>5}x{:<5} {:>7.1}ms {:>7.1}ms {:>7.1}MB", + row.label, + row.width, + row.height, + row.times.p50, + row.times.p99, + row.bytes as f64 / 1e6 + ); + } + println!(); + println!(" No GPU render is in these figures, so they cannot pass NFR-P7 — only fail"); + println!(" it. See tools/bench/src/exporting.rs for why that is still worth gating."); + println!(); + + println!("Idle memory with the catalog open (NFR-P8's catalog share)"); + match rss { + Some(rss) => { + println!( + " {:>10.1} MB resident after opening 50k and scrolling 10k rows", + rss.now_mb() + ); + println!(" {:>10.1} MB peak for that process", rss.peak_mb()); + println!(); + println!(" RSS is exclusive of device-local GPU memory and this process has no"); + println!(" toolkit, no adapter and no decode cache — so it is the catalog layer's"); + println!(" share of NFR-P8's 500 MB, not NFR-P8. tools/bench/src/memory.rs holds"); + println!(" the answer to the question §4.1 asks, and the change it recommends."); + } + None => println!(" not measured on this platform"), + } + println!(); +} + +/// Print the gate table and return the failures, one line each. +fn print_verdict( + base: &Baseline, + values: &BTreeMap, + cx: Judging, + machine: &str, + comparable_fixture: bool, +) -> Vec { + println!("Against docs/bench-baseline.json"); + match (&base.recorded_on, cx.same_machine) { + (None, _) => println!( + " No baseline has been recorded yet. Budgets are still gated; drift is not.\n \ + Run `dr-bench record --reference` on the reference desktop and commit the diff." + ), + (Some(on), true) => println!(" Recorded on {on} — drift below is a verdict."), + (Some(on), false) if !comparable_fixture => println!( + " Recorded on {on} against a different fixture; drift below is information only." + ), + (Some(on), false) => println!( + " Recorded on {on}, and this is {machine}. Drift below is information, not a verdict." + ), + } + if !cx.reference { + println!( + " Not the reference desktop (--reference), so machine-sensitive budgets are\n \ + reported rather than asserted — §8 names the reference desktop, not CI." + ); + } + println!(); + println!( + " {:<30} {:>12} {:>13} {:>12} {:>8} verdict", + "metric", "measured", "budget", "baseline", "drift" + ); + + let mut failures = Vec::new(); + for (key, measured) in values { + let Some(metric) = base.metrics.get(key) else { + println!( + " {key:<30} {measured:>12.2} {:>13} {:>12} {:>8} not in the baseline", + "—", "—", "—" + ); + continue; + }; + let j = metric.judge(*measured, cx); + let budget = match (metric.budget, metric.direction) { + (None, _) => "—".to_string(), + (Some(b), baseline::Direction::LowerIsBetter) => format!("< {b:.1}"), + (Some(b), baseline::Direction::HigherIsBetter) => format!("> {b:.1}"), + }; + let recorded = match metric.recorded { + Some(r) => format!("{r:.2}"), + None => "—".to_string(), + }; + let drift = match j.drift { + Some(d) => format!("{:+.1}%", d * 100.0), + None => "—".to_string(), + }; + let mut verdict = String::new(); + if j.over_budget { + verdict.push_str("OVER BUDGET "); + failures.push(format!( + "{key} is {measured:.2} {}, past {budget} ({})", + metric.unit, metric.requirement + )); + } + if j.regressed { + verdict.push_str("REGRESSED "); + failures.push(format!( + "{key} drifted {drift} against the baseline, past the {:.0}% tolerance", + cx.tolerance * 100.0 + )); + } + if verdict.is_empty() { + verdict.push_str(if j.budget_deferred { + "ok (budget deferred)" + } else { + "ok" + }); + } + println!(" {key:<30} {measured:>12.2} {budget:>13} {recorded:>12} {drift:>8} {verdict}"); + } + + // A metric in the file that nothing measured is a harness that has drifted + // from its own record, and is worth saying out loud rather than leaving as + // a row that quietly stopped appearing. + for key in base.metrics.keys() { + if !values.contains_key(key) { + println!(" {key:<30} {:>12} measured nothing this run", "—"); + } + } + + failures +} diff --git a/tools/bench/src/memory.rs b/tools/bench/src/memory.rs new file mode 100644 index 0000000..7c57e78 --- /dev/null +++ b/tools/bench/src/memory.rs @@ -0,0 +1,182 @@ +//! Idle memory with a 50k catalog open — and the question NFR-P8 leaves open. +//! +//! # The question §4.1 asks, answered +//! +//! §4.1 says of NFR-P8: *"must state whether it measures RSS inclusive or +//! exclusive of GPU allocations, and whether it holds after SQLite's page cache +//! warms on a 50k catalog."* Both halves have an answer, and neither is +//! flattering. +//! +//! **On GPU memory: what this reports is RSS, and RSS is exclusive of +//! device-local GPU allocations.** A Vulkan allocation in device-local heap +//! never enters the process's address space, so no counter under +//! `/proc/self/status` can see it; what *does* land in RSS is the host-visible +//! side — staging buffers, mapped upload rings, the read-back `AdjustPass` +//! performs on export — and the driver's own resident pages. So "RSS < 500 MB" +//! is not one budget, it is two questions wearing one number, and a build that +//! kept RSS at 400 MB while holding 3 GB of textures would pass it. +//! +//! The recommendation this measurement exists to support: **NFR-P8 should be +//! restated as two figures** — host RSS exclusive of device-local memory, and +//! a separate VRAM ceiling read from the adapter — because the second is the +//! one that decides whether the application survives beside a browser on an +//! 8 GB card, and nothing in this repository currently measures it. +//! +//! **On the page cache: warm.** The probe runs the queries before it reads the +//! counter, so SQLite's page cache holds the b-tree pages a grid scroll +//! touches. That is the right side to err on — a figure taken before the cache +//! warms would understate a steady-state library — and it is why the probe +//! scrolls rather than opening and stopping. +//! +//! # Why this is a subprocess +//! +//! RSS is a high-water-influenced property of a *process*, not of a function. +//! Building a 50k fixture allocates hundreds of megabytes; decoding thumbnails +//! allocates more; the allocator returns some of it to the OS and keeps the +//! rest. Measuring after any of that would report the harness's history rather +//! than the catalog's cost. So the probe is a fresh process that opens the +//! catalog, does the grid's work, reads its own counters and exits. +//! +//! # What this cannot certify, said plainly +//! +//! Not NFR-P8. The requirement is about the *application* at idle — Slint, the +//! wgpu device, the font stack, the decode cache and the catalog together — and +//! this process contains only the last of those. No requirement tag in this +//! crate names NFR-P8, for that reason — and see `exporting.rs` for why that +//! sentence avoids spelling the tag out. +//! +//! What it is, is the catalog layer's share, measured rather than guessed. The +//! decision NFR-P8 actually needs — how much of the 500 MB belongs to the +//! catalog and how much to everything above it — is a decision somebody has to +//! take, and taking it against a recorded number is better than taking it +//! against an estimate. That is what this records. Until it is taken, the +//! metric carries no budget and gates only against its own baseline. + +use std::path::Path; + +use anyhow::Result; +use dr_catalog::{Catalog, Granularity, Query}; + +/// Rows fetched per window while the probe scrolls. The same 400 +/// [`crate::catalog_open`] uses, for the same reason. +const WINDOW: usize = 400; + +/// Windows the probe pages through before reading the counter. +/// +/// Twenty-five is ten thousand rows: enough that SQLite's page cache holds a +/// realistic working set and that any per-window leak would be visible, and +/// far short of the whole library, which FR-CAT-4 forbids holding anyway. +const WINDOWS: usize = 25; + +/// A fixed clock, for the reason `catalog_open`'s `NOW` gives: nothing here +/// should depend on the day it runs. +const NOW: i64 = 2_000_000_000; + +/// Resident memory, in kilobytes, as Linux reports it. +#[derive(Debug, Clone, Copy)] +pub struct Rss { + /// `VmRSS`: resident now. + pub now_kb: u64, + /// `VmHWM`: the peak this process reached. Reported alongside because a + /// process that touched 900 MB and gave it back is not idling at 200 MB in + /// any sense a user would recognise — the pages came from somewhere. + pub peak_kb: u64, +} + +impl Rss { + pub fn now_mb(&self) -> f64 { + self.now_kb as f64 / 1024.0 + } + + pub fn peak_mb(&self) -> f64 { + self.peak_kb as f64 / 1024.0 + } +} + +/// Read this process's own counters. +/// +/// `None` anywhere without a Linux-shaped `/proc` — including Android, where +/// the file exists but a benchmark does not run, and macOS, where it does not. +/// Returning `None` rather than zero is deliberate: a memory figure of zero +/// would be reported as an excellent result. +pub fn of_this_process() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let mut now = None; + let mut peak = None; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + now = rest.split_whitespace().next()?.parse::().ok(); + } else if let Some(rest) = line.strip_prefix("VmHWM:") { + peak = rest.split_whitespace().next()?.parse::().ok(); + } + } + Some(Rss { + now_kb: now?, + peak_kb: peak?, + }) +} + +/// The probe: open the catalog, do what the grid does, print the counters. +/// +/// Stdout is one line of `key=value` pairs rather than JSON, because the only +/// reader is [`in_a_fresh_process`] and a format a human can read in a log is +/// worth more here than one a parser prefers. +pub fn probe(catalog_path: &Path) -> Result<()> { + let catalog = Catalog::open(catalog_path) + .map_err(|e| anyhow::anyhow!("opening {} : {e}", catalog_path.display()))?; + let q = Query::default(); + + let images = catalog.count(&q, NOW)?; + let buckets = catalog.timeline(&q, Granularity::Month, NOW)?.len(); + + // Scroll, keeping only the window in hand — which is what the grid does, + // and what FR-CAT-4 requires it to do. If this ever starts costing memory + // proportional to how far the user scrolled, that is the bug this figure + // exists to catch. + let mut rows = 0usize; + let span = images.saturating_sub(WINDOW).max(1); + for i in 0..WINDOWS { + let start = (i * span) / WINDOWS.max(1); + rows = catalog.window(&q, start..start + WINDOW, NOW)?.len(); + } + + let Some(rss) = of_this_process() else { + anyhow::bail!("no /proc/self/status on this platform; RSS cannot be read"); + }; + println!( + "rss_kb={} peak_kb={} images={images} buckets={buckets} last_window={rows}", + rss.now_kb, rss.peak_kb + ); + Ok(()) +} + +/// Run [`probe`] in a fresh copy of this executable and read back its counters. +pub fn in_a_fresh_process(catalog_path: &Path) -> Result { + let exe = std::env::current_exe()?; + let output = std::process::Command::new(&exe) + .arg("memory-probe") + .arg(catalog_path) + .output()?; + + if !output.status.success() { + anyhow::bail!( + "the memory probe exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let text = String::from_utf8_lossy(&output.stdout); + let field = |key: &str| -> Option { + text.split_whitespace() + .find_map(|pair| pair.strip_prefix(key)) + .and_then(|v| v.parse::().ok()) + }; + let (Some(now_kb), Some(peak_kb)) = (field("rss_kb="), field("peak_kb=")) else { + anyhow::bail!( + "the memory probe printed something unreadable: {}", + text.trim() + ); + }; + Ok(Rss { now_kb, peak_kb }) +} diff --git a/tools/bench/src/stats.rs b/tools/bench/src/stats.rs new file mode 100644 index 0000000..8dad9f5 --- /dev/null +++ b/tools/bench/src/stats.rs @@ -0,0 +1,122 @@ +//! Ranking a set of samples, the way `dr-gpu`'s frame budget ranks them. +//! +//! Copied in spirit rather than shared, because the two live in different +//! dependency worlds — `core/dr-gpu/examples/frame_budget.rs` is an example +//! inside a crate this one deliberately does not depend on (see `Cargo.toml`). +//! The arithmetic is identical on purpose: two percentile definitions in one +//! repository is how two benchmarks come to disagree about the same machine. +//! +//! # Nearest-rank, not an interpolating definition +//! +//! The samples *are* the population. There is no distribution being estimated +//! here, only a set of catalog opens or thumbnail encodes that either happened +//! inside the target or did not. At 100 samples the 99th percentile is the +//! second-worst, which is the honest reading of "one bad one in a hundred is +//! one too many" without letting a single scheduler hiccup on an unrelated +//! process decide the verdict. + +use std::time::Duration; + +/// Nearest-rank percentiles over a set of samples, in the caller's unit. +#[derive(Debug, Clone, Copy)] +pub struct Percentiles { + pub p50: f64, + pub p99: f64, + pub max: f64, +} + +impl Percentiles { + /// Rank `samples`. Panics on an empty set, which is a harness bug rather + /// than a measurement: a row with nothing in it must not print a zero that + /// reads like a very fast result. + pub fn of(mut samples: Vec) -> Self { + assert!( + !samples.is_empty(), + "percentiles of an empty sample set — the measurement produced nothing" + ); + samples.sort_by(f64::total_cmp); + let rank = |p: f64| { + let n = samples.len(); + let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1; + samples[i] + }; + Percentiles { + p50: rank(0.50), + p99: rank(0.99), + max: samples[samples.len() - 1], + } + } +} + +/// A duration in milliseconds, which is the unit every timing here is stated +/// in. One spelling, so no row is accidentally in seconds. +pub fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1e3 +} + +/// A deterministic generator, so a fixture is reproducible from its seed. +/// +/// SplitMix64. Chosen because it is eight lines, has no dependency, and passes +/// the only test that matters here — that the same seed produces the same +/// catalog on the reference desktop and on the CI runner, so a number measured +/// in one place describes the same workload as a number measured in the other. +/// Nothing cryptographic depends on it. +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed) + } + + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A value in `0..n`. Modulo-biased, which does not matter for a fixture: + /// nothing here is a statistical test, only a spread of plausible values. + pub fn below(&mut self, n: u64) -> u64 { + self.next_u64() % n.max(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_ninety_ninth_of_a_hundred_is_the_second_worst() { + // The property the whole suite's verdict rests on. Off by one here and + // every threshold is judged against the worst sample instead. + let samples: Vec = (1..=100).map(|n| n as f64).collect(); + let p = Percentiles::of(samples); + assert_eq!(p.p99, 99.0); + assert_eq!(p.max, 100.0); + assert_eq!(p.p50, 50.0); + } + + #[test] + fn a_single_sample_ranks_as_itself() { + // A row measured once — a cold catalog open — must not divide by zero + // or index off the end. + let p = Percentiles::of(vec![7.5]); + assert_eq!((p.p50, p.p99, p.max), (7.5, 7.5, 7.5)); + } + + #[test] + fn the_same_seed_gives_the_same_sequence() { + // Reproducibility from a seed is what makes a committed baseline mean + // anything: two runs must describe the same catalog. + let mut a = Rng::new(20_260_829); + let mut b = Rng::new(20_260_829); + let mut c = Rng::new(20_260_830); + let first: Vec = (0..8).map(|_| a.next_u64()).collect(); + let same: Vec = (0..8).map(|_| b.next_u64()).collect(); + let other: Vec = (0..8).map(|_| c.next_u64()).collect(); + assert_eq!(first, same); + assert_ne!(first, other); + } +} diff --git a/tools/bench/src/thumbnails.rs b/tools/bench/src/thumbnails.rs new file mode 100644 index 0000000..0768eec --- /dev/null +++ b/tools/bench/src/thumbnails.rs @@ -0,0 +1,272 @@ +//! TRACES: NFR-P3 +//! Thumbnail throughput on the embedded preview path. +//! +//! NFR-P3 asks for **≥ 100 images per second** on the reference desktop +//! through the embedded preview path, and ≥ 25 on a mid-range Android device. +//! Nothing had ever counted. +//! +//! # What is timed, and why it is not the sweep itself +//! +//! `ui/dr-ui/src/library.rs`'s [`spawn_thumbnail_sweep`] is the whole-library +//! pass, and it is the machinery this mirrors: a chunk at a time, lanes owning +//! disjoint slices, every lane decoding and encoding on its own, and the +//! single thread that owns the store writing the finished chunk. That shape is +//! reproduced here because it is the shape that decides the number — where the +//! parallelism is, and where the one lock is. +//! +//! It is *mirrored* rather than called, for a reason worth stating plainly: +//! that function takes a `RemoteBackend` and spends most of its wall clock in +//! WebDAV round trips. Calling it from a benchmark would need a Nextcloud +//! server, and what it would then measure is somebody's network. The per-image +//! work is identical either way — `dr_decode::decode_jpeg`, +//! `Preview::downscale_to`, `Preview::apply_orientation`, +//! `dr_thumbs::encode_rgba`, `ThumbStore::put` — and that work is what a target +//! written in images per second is about. +//! +//! So: **the bytes are already in memory when the clock starts.** This is CPU +//! throughput for the preview path with the fetch paid for, which is what a +//! local library gives you and what the target's "embedded preview path" +//! names. It is not a claim about a remote library, whose ceiling is latency +//! and which [`spawn_thumbnail_sweep`] exists to hide rather than to beat. +//! +//! # The plain-JPEG branch, deliberately +//! +//! `fetch_preview` has two arms: a plain JPEG is its own preview, and anything +//! else is located inside the container first. The fixture's files take the +//! first arm, so `locate_preview` is not in the measured span. That is honest +//! for two reasons — a library of camera JPEGs is a real library and takes +//! exactly this path, and for a RAW the located preview *is* a JPEG of about +//! this size, so what changes is a header walk of a few microseconds against a +//! decode of several milliseconds. What is genuinely not measured is the +//! container parse of an exotic format, and nothing here pretends otherwise. +//! +//! [`spawn_thumbnail_sweep`]: ../../dr_ui/library/fn.spawn_thumbnail_sweep.html + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use dr_thumbs::{ThumbSize, ThumbStore, Thumbnail}; +use dr_types::Orientation; + +use crate::stats::{ms, Percentiles}; + +/// Images per chunk handed back to the thread that owns the store. +/// +/// 96, which is `SWEEP_CHUNK` in `library.rs`. Copied rather than chosen: the +/// point of this row is to describe the sweep's behaviour, and a different +/// chunk size would move the ratio of lane work to store work. +const CHUNK: usize = 96; + +/// The size class the whole-library pass fills. +/// +/// Grid only, which is `SWEEP_THUMB_SIZE`. The large class is four times the +/// transfer for a detail only a zoomed cell asks for, so the sweep does not +/// produce it and neither does this. +const SIZE: ThumbSize = ThumbSize::Grid; + +/// What a measured sweep produced. +pub struct Throughput { + pub images: usize, + pub lanes: usize, + pub wall_ms: f64, + pub images_per_second: f64, + /// Per image, on the lane that produced it: decode, downscale, orient, + /// encode. Not the store write, which happens elsewhere by design. + pub per_image: Percentiles, + /// Thumbnails that reached the store. + pub stored: usize, + /// Images whose preview would not decode. Any non-zero is a broken + /// fixture, not a slow one. + pub failed: usize, +} + +/// One lane's output: what it made, what each one cost, and what it dropped. +struct Lane { + made: Vec<(u64, Thumbnail)>, + times: Vec, + failed: usize, +} + +/// Sweep `images` thumbnails from `sources`, `lanes` at a time. +/// +/// `store_dir` is emptied first. Re-storing an id that is already present is +/// an `UPDATE` in place rather than an insert (see `ThumbStore::put`), and a +/// run that measured updates would not be measuring the pass this describes — +/// the sweep's work list is by construction what the store does *not* have. +pub fn measure( + sources: &[PathBuf], + store_dir: &Path, + images: usize, + lanes: usize, +) -> Result { + anyhow::ensure!(!sources.is_empty(), "no fixture sources to sweep"); + anyhow::ensure!(lanes > 0, "a sweep needs at least one lane"); + + let bytes: Vec> = sources + .iter() + .map(|p| std::fs::read(p).with_context(|| format!("reading {}", p.display()))) + .collect::>()?; + + let _ = std::fs::remove_dir_all(store_dir); + let mut store = ThumbStore::open(store_dir) + .map_err(|e| anyhow::anyhow!("opening the bench thumbnail store: {e}"))?; + + // Warm-up: every source decoded once, discarded. The first decode of a + // file faults in its Huffman tables and grows the allocator's arenas to + // the size a 1620x1080 RGBA buffer needs, and neither recurs across a + // sweep of thousands. + let warm_failures = Lane::run(&bytes, (0..bytes.len()).collect()).failed; + anyhow::ensure!( + warm_failures == 0, + "{warm_failures} of the {} fixture sources would not decode", + bytes.len() + ); + + let mut samples: Vec = Vec::with_capacity(images); + let mut stored = 0usize; + let mut failed = 0usize; + + let started = Instant::now(); + for chunk_start in (0..images).step_by(CHUNK) { + let chunk_end = (chunk_start + CHUNK).min(images); + // Each lane takes every `lanes`-th image of the chunk, which is how + // `spawn_thumbnail_sweep` splits one: disjoint slices, nothing shared, + // no lock. + let work: Vec> = (0..lanes) + .map(|lane| (chunk_start..chunk_end).skip(lane).step_by(lanes).collect()) + .collect(); + + let source_slice: &[Vec] = &bytes; + let produced: Vec = std::thread::scope(|scope| { + let handles: Vec<_> = work + .into_iter() + .map(|lane| scope.spawn(move || Lane::run(source_slice, lane))) + .collect(); + handles + .into_iter() + .map(|h| h.join().expect("a sweep lane panicked")) + .collect() + }); + + // The store is `&mut` and single-writer, so the chunk is written here + // and not on the lanes. This is the sweep's own discipline and it is + // part of the number: if the store were the bottleneck, no amount of + // lane parallelism would help and the row would say so. + for lane in produced { + samples.extend(lane.times); + failed += lane.failed; + for (file_id, thumb) in lane.made { + match store.put(file_id, SIZE, &thumb) { + Ok(_) => stored += 1, + Err(e) => { + log::warn!("storing bench thumbnail {file_id}: {e}"); + failed += 1; + } + } + } + } + } + let wall = started.elapsed(); + + anyhow::ensure!( + failed == 0, + "{failed} of {images} thumbnails failed; the throughput below would be \ + measuring how fast this gives up" + ); + + let wall_ms = ms(wall); + Ok(Throughput { + images, + lanes, + wall_ms, + images_per_second: images as f64 / wall.as_secs_f64().max(f64::MIN_POSITIVE), + per_image: Percentiles::of(samples), + stored, + failed, + }) +} + +impl Lane { + /// Turn each of `work`'s previews into a stored thumbnail's worth of bytes. + /// + /// The four calls, in the order `fetch_preview` and `encode_preview` make + /// them. `is_complete_jpeg` is included because it is in the real path and + /// because leaving it out would be the sort of small omission that turns a + /// measurement into an estimate: a truncated JPEG decodes "successfully" + /// into a partial frame, so the check is not optional and its cost is not + /// somebody else's. + fn run(sources: &[Vec], work: Vec) -> Lane { + let mut made = Vec::with_capacity(work.len()); + let mut times = Vec::with_capacity(work.len()); + let mut failed = 0usize; + + for index in work { + let bytes = &sources[index % sources.len()]; + // The fixture makes every fourth image portrait, so a quarter of + // these pay for the permutation `apply_orientation` performs. In a + // real library that fraction is whatever the photographer shot. + let orientation = if index % 4 == 3 { + Orientation::from_exif(6) + } else { + Orientation::default() + }; + + let started = Instant::now(); + if !dr_decode::is_complete_jpeg(bytes) { + failed += 1; + continue; + } + let mut preview = match dr_decode::decode_jpeg(bytes) { + Ok(p) => p, + Err(e) => { + log::debug!("bench preview {index}: {e}"); + failed += 1; + continue; + } + }; + preview.downscale_to(SIZE.edge()); + preview.apply_orientation(orientation); + let rgba = &preview.rgba; + let encoded = match dr_thumbs::encode_rgba(preview.width, preview.height, rgba) { + Ok(b) => b, + Err(e) => { + log::debug!("bench thumbnail {index}: {e}"); + failed += 1; + continue; + } + }; + times.push(ms(started.elapsed())); + + made.push(( + index as u64 + 1, + Thumbnail { + width: preview.width, + height: preview.height, + bytes: encoded, + }, + )); + } + + Lane { + made, + times, + failed, + } + } +} + +/// How many lanes to sweep with by default. +/// +/// The machine's threads, not `SWEEP_LANES`. The sweep's six are sized for +/// *latency* — each image is ~0.6 s of WebDAV round trip and almost no CPU, so +/// six in flight is a queue depth rather than a core count. With the bytes +/// already in memory the work is purely CPU, and six lanes on a 24-thread +/// desktop would report a quarter of the throughput the machine has. Stated +/// here rather than buried, because it is the one place this deviates from the +/// shape it otherwise copies. +pub fn default_lanes() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) +} diff --git a/tools/traceability/src/gestures.rs b/tools/traceability/src/gestures.rs index 3d10f00..c0783bf 100644 --- a/tools/traceability/src/gestures.rs +++ b/tools/traceability/src/gestures.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-UI-4 | NFR-OPS-1 +//! TRACES: FR-UI-4 //! Interaction documentation, extracted from the code that implements it. //! //! # Why this exists at all diff --git a/tools/traceability/src/lib.rs b/tools/traceability/src/lib.rs index bbc8e29..fa648fc 100644 --- a/tools/traceability/src/lib.rs +++ b/tools/traceability/src/lib.rs @@ -19,6 +19,19 @@ //! count as the numerator is precisely what lets a ratio exceed 100%, since //! a tag naming a deleted requirement would count as covered. Such tags are //! reported as orphans instead. +//! +//! # And why the extractor is fussy about where a tag sits +//! +//! A third rule, learned the same way. `SOURCE_ROOTS` includes `tools`, so this +//! crate scans itself, and the fixtures below demonstrating tag extraction were +//! read as tags: R1 — cross-platform output within a bounded tolerance — was +//! reported implemented on the strength of two string literals in a unit test. +//! +//! 3. **A tag is a comment whose first word is `TRACES:`**, not a line in which +//! the string appears. See `tag_body`. It is a rule about position rather +//! than about string literals, because `schema.rs` writes six real tags +//! *inside* string literals — the SQL it embeds is commented with `--` — so +//! an extractor that refused string literals would lose more than it saved. use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; @@ -182,16 +195,43 @@ pub fn is_requirement(id: &str) -> bool { REQUIREMENT_TYPES.contains(&type_of(id)) } +/// Comment openers a tag may be introduced by. +/// +/// `--` is here for the SQL embedded in `schema.rs`, which carries real tags +/// inside Rust string literals; `#` for YAML; `*` for the continuation lines of +/// a block comment. +const COMMENT_OPENERS: &[&str] = &["///", "//!", "//", "