Commit Graph
368 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 df06240b2d Test that the category geometry means what it says
The scene tests so far checked the descriptor and the arithmetic. Neither
would have noticed if `rasterise` put the sky along the bottom of the frame,
because both build their own weights and never ask where those weights land.

Three that do:

- **Weight stays on the side it came from.** Fill the top half of the grid,
  read the top and bottom quarters of the image. A flipped y axis is the
  mistake this code is actually prone to — it is a letterbox inverse, and the
  numbers stay perfectly plausible when it is wrong.
- **No transpose.** The vertical check alone passes under a transpose, which
  maps a top band onto a left band. A horizontal split is what distinguishes
  them, and neither test is worth much without the other.
- **Coverage is a fraction.** A quarter of the cells must read 0.25. The
  scene tab hides a category below half a percent, so an error of a factor of
  the grid size would hide everything or nothing — and both look like the
  model failing rather than the arithmetic.

`Scene::from_weights` is test-only and exists because the property under test
needs weights whose correct destination is known in advance, which no real
inference can provide. It uses a square window so the letterbox is the
identity: any offset these find is the mapping's own rather than the
padding's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:28:38 +02:00
dtourolleandClaude Opus 5 86260b5028 Bind a category layer to its own distance field
A category mask showed nothing and its adjustment covered the whole
photograph. Both from one line: the loop in `MaskPass::rasterise` picks a
distance field by matching `layer.source`, that match named only `Subject`,
and a `Category` layer fell through to `_ => (&self.empty_subject, 0)` — a
1x1 placeholder. No field, so nothing to draw and nothing to confine the
adjustment.

The comment three lines above the arm I missed describes the failure I then
shipped:

    an absent mask that defaults to "everything" would apply the
    adjustment to the whole photograph

There are *two* matches on `layer.source` in that loop — one choosing the
field, one building the params. Adding the category to the second and not
the first compiles, runs, and is wrong in exactly the way the first one
warns about.

## Also: a missing mask must still be the right size

Both model-backed arms of `ensure_subject_fields` used `unwrap_or_default`,
which yields an empty `Vec` when the coverage is gone. `SubjectMasks::upload`
rejects a wrong-sized field and fails the whole batch, so `self.subjects`
becomes `None` and *every* layer in the stack loses its mask — one stale
reference silently unmasking the others.

Pre-existing, and it mattered less when the only model-backed source was a
subject: an instance index goes missing rarely. A category name goes missing
whenever the descriptor is edited, which is a thing the descriptor exists to
allow. A full-size empty field costs one layer instead of all of them.

Neither of these is reachable from a test on this machine — both live past a
GPU adapter and a real segmentation — so they surfaced the only way they
could, by someone opening the app and looking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:28:27 +02:00
dtourolleandClaude Opus 5 763dfd353a Weigh the categories in the same precompute, and mask with them
The scene model shipped with a decoder and no caller. This runs it.

## Beside the instance pass, not instead of it

`compute` now does both on the same upright frame and lays both back down
the same way, so instance masks and category masks index into one grid —
the sensor's. A failure in the scene half is logged and dropped rather than
propagated: no scene model is an ordinary state, and a photograph that can
still be masked by subject should not become unopenable because the
categories are missing.

Categories under half a percent of the frame never reach the cache. A
control that does nothing when moved is worse than an absent one, and each
one it skips is a proxy-sized buffer not allocated.

## The shader needed nothing

A category reaches `dr-gpu` as a soft coverage buffer at proxy resolution,
turned into a distance field — which is exactly what a subject is. So they
share `MODE_SUBJECT`. That is not a shortcut taken for speed: the shader has
no way to tell them apart and no reason to want one. What differs is only
which model produced the coverage, and that has already happened by then.

Feather, falloff, dilation and erosion therefore work on a category on the
day it arrives, because they were never subject-specific.

## Where the weights come from

`scene-model` compiles the graph in and the desktop app takes it; Android
leaves it off and reads the copy `install_bundled_models` unpacks, because
24 MB of constant is worth avoiding in a mobile install and not worth the
plumbing to avoid on a desktop one. Embedded is tried first — a build that
has the weights compiled in should not be silently overridden by a stale
file in a data directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:22 +02:00
dtourolleandClaude Opus 5 485297f0c6 Let a mask cover a whole category, not just one instance
`MaskSource` could say "instance 3 of that segmentation run" but had no way
to say "the sky". Adding `Category { signature, name }` beside `Subject` is
what lets a local adjustment attach to a semantic category at all.

Stored as identity like a subject, and for the same reason: the coverage is
megabytes and is reproducible by running the same model over the same image,
so the sidecar carries what finds it again and the session carries pixels.

## A name rather than an index

An index would be smaller and would match `Subject`. It would also be a bug.
The grouping lives in `models/scene/categories.txt`, which is editable by
design — adding one category to it renumbers every category after it, and
every stored layer would silently start grading something else. A name that
no longer exists is simply not found and the layer reads as stale, which is
the failure that announces itself.

Staleness is otherwise identical to a subject's: the coverage buffer is in
the session, never the sidecar, so a signature from another run points at
pixels that were never computed.

## Two tests, and the second one caught a real shape

Round-tripping the name matters more than usual here, because the whole
argument for storing a name instead of an index is worthless if the sidecar
is what drops it.

The multi-word case is the one worth having: `category = swimming pool` is
written on one line, and a reader splitting on whitespace would have
truncated it to a category no model has — a layer that silently masks
nothing. `category` is also its own key rather than a reuse of `class`,
because a file conflating them would round-trip a subject into a category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:02:08 +02:00
dtourolleandClaude Opus 5 d70dcf78d1 Merge: recover a damaged catalog, and capture a crash locally
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 13:45:18 +02:00
dtourolle c48896bd95 Merge: touch selection and drag, from the gallery-selection branch
Verified before merge: fmt clean, clippy -D warnings clean, 563 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-30 13:45:18 +02:00
dtourolleandClaude Opus 5 8df6000e4b Decode the scene model into per-category weights
The weights landed last commit with nothing to read them. This is the
decoder, and the shape of it follows from one property worth stating
before the code: the categories must partition the image.

## Why a partition, and not a mask per category

The scene tab applies one grade to every pixel of a category — lift the
sky, desaturate foliage — and both grades meet at the horizon. If each
category carried an independent mask, feathering them outward would make
the boundary band belong to both, so both grades would land there and
every horizon would acquire a visible seam. Feathering has to *blend*
there, not accumulate.

So `marginalise` takes one softmax over all 150 channels and sums within
each category. Grouping cannot change a total of one, so the listed
categories plus the unlisted remainder sum to one at every pixel, by
construction rather than by normalising afterwards. `parse_categories`
refuses a descriptor that claims a class twice, because that is the one
input that would quietly make the property untrue.

## The descriptor is data, and hand-written

`models/scene/categories.txt` groups ADE20K's 150 classes into the eight
a photographer would recognise. It is a file rather than a table in Rust
for the reason `models/LICENCE.md` predicted — a vocabulary is model
metadata — and it is line-oriented with comments rather than JSON like
the `.classes.json` beside it, because that file is generated and this
one is argued. Why `swimming pool` is water and not architecture belongs
next to the line that says so.

Classes are named, not indexed. An index is silently wrong after a
re-export; a name is loudly wrong, and the loader refuses one the model
does not have.

## Resolution, kept visible

`Scene` holds the native 80×80 logit grid and resamples on demand rather
than upsampling once at load. The coarseness is real — it is what the
graph produces — and a type that hides it behind an early resize invites
callers to expect detail that was never there. `rasterise` is where the
letterbox inverse lives, once.

`Letterbox` and `Window` become `pub(crate)` and `to_proto` generalises
to `to_grid`, because both dense outputs this crate reads are an even
fraction of the same letterboxed square and differ only in the divisor.

## Verified by looking, which is the only way this gets verified

`examples/scene.rs` writes the photograph dimmed outside each category. A
transposed axis or an off-by-one in the inverse produces perfectly
plausible weights over slightly the wrong pixels, and no unit test
catches that. On an indoor frame the person mask lands on the person,
including the outstretched arm, and sky reads ~5% against a bright
ceiling.

It doubles as the benchmark, because every timing quoted while this model
was chosen came off a laptop compiling other things and none of them
belong in a document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:48:37 +02:00
dtourolleandClaude Opus 5 8eeb9ba0f6 Offer the backup, and then the rebuild, when the index turns out to be damaged
NFR-R6 asks for an integrity check at startup and two offers behind it, and
none of it existed. `PRAGMA integrity_check` appeared nowhere in the tree,
`Catalog::open` was `open` → `configure` → `migrate` → `backfill` and nothing
else, and corruption therefore surfaced as whatever rusqlite error the first
unlucky query happened to produce — "database disk image is malformed"
attached to a thumbnail refresh, elided into a 34px banner, over an empty
grid saying "No images found · Check the library folder". Two messages that
disagreed, and no way forward but deleting catalog.sqlite by hand.

The property that makes the second offer real was already here and load-
bearing: the catalog is an index, not a source of truth, rebuildable from
sources plus sidecars (invariant §5.2.4, cited by schema.rs, trash.rs and
lib.rs). And sync.rs already knew how to take a coherent snapshot of a WAL
database. What was missing was the check, the type, and the conversation.

Four pieces:

**The type.** `CatalogError::Corrupt`, and — the part that makes it worth
having — a hand-written `From<rusqlite::Error>` that classifies rather than
wraps. `SQLITE_CORRUPT` and `SQLITE_NOTADB` become `Corrupt` wherever they
arise, so a background job that trips over the damage first reports the same
thing the startup check would have. `SQLITE_IOERR` and `SQLITE_BUSY`
deliberately do not: a dropped network mount is a different problem, and
telling someone to rebuild their index would be a wrong answer delivered
confidently.

**The check.** `Catalog::open_verified`, `quick_check` before the open rather
than after, because opening runs migrations and a damaged catalog with an
intact header would otherwise have structure rewritten on top of structure
that is already wrong. Bound to `open_verified` and not to `open`: the check
reads every page, which is affordable once at startup where a user can answer
a question, and not affordable on the dozens of opens a session's background
tasks make.

**The backup.** NFR-R2's second clause, taken between `configure` and
`migrate` in `Catalog::open`. A migration is the one routine operation that
rewrites table structure, so it is the likeliest way this file becomes
unreadable, and it is the last moment the pre-migration state exists to be
copied. Three generations, through SQLite's backup API after a TRUNCATE
checkpoint — never `fs::copy`, which on a WAL database backs up a state older
than the catalog and possibly torn. A failure to take the copy is logged, not
raised: a full disk must not be what makes a library unopenable.

**The conversation.** The first line of the dialogue is that the photographs
and the edits are safe, before the diagnosis, because that is the question the
user is actually asking. Then the two offers, which are *not* interchangeable
and are not presented as if they were: a restore keeps collections, and a
rebuild cannot, because a manual collection is a set of images assembled by
hand and nothing in the filesystem records it (docs/catalog.md §8.1). The
labels say so, and the rebuild does not take the affirmative styling while a
restore is on the table.

One thing that is a fix rather than a feature: `show_catalog_now` now gates
the scan. `Catalog::open` succeeds on a file whose header survived, so the
scan that used to start immediately afterwards would write folder ETags and
image rows into damaged pages in the seconds while the user was still reading
the question — turning a file that had a backup into one where the backup is
the only copy left.

Restore also deletes the damaged catalog's `-wal` and `-shm`. That step 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.

Tested by corrupting a fixture catalog — 500 images and a collection, then
every page past the second overwritten — and driving both branches. The
restore is asserted on the collection, because a collection is precisely what
distinguishes the two paths; the rebuild on the damaged file being kept and
the next open producing an empty catalog at the current schema. Plus the
`SQLITE_NOTADB` presentation, a damaged backup being refused rather than
installed, and a v1 catalog whose pre-migration backup comes back reading
v1 rather than v11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:34:12 +02:00
dtourolleandClaude Opus 5 99563b33f7 Record the one clause of FR-PLG-8 that is built, and say what it is short of
FR-PLG-8 reads as unbuilt, and most of it is: no aggregated missing-plugin
notice, no catalog-wide view, no export gate, no mark on an image whose
operation is unavailable. But its opening sentence is a claim about existing
behaviour — "the sidecar already preserves lines it does not understand
verbatim and writes them back untouched" — and it goes on to say that property
"is now load-bearing and shall be treated as such".

Two tests treat it as such, and neither was tagged. `sidecar.rs` keeps an
unknown operation across a parse-and-write, and keeps it out of the edit graph
so that preserving it is safe rather than merely tidy. Both would fail if the
verbatim path were removed, which is what CONTRIBUTING.md asks a tag to mean.

The tag sits on the two tests rather than on the module, so the matrix points
at the clause that is closed rather than at the requirement as a whole.

It is still short of the requirement's own acceptance criterion, and the tag
comment says so: FR-PLG-8 asks that a sidecar written with a plugin, opened and
saved without it, be byte-identical to the original, and the test asserts
`contains`. Both halves exist separately — `writing_the_same_state_twice_is_byte_identical`
proves byte identity for content this build understands — and nothing joins
them into the single claim the requirement makes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:20:22 +02:00
dtourolleandClaude Opus 5 62e585844a Untag FR-PLAT-AND-1 from the two places that record its absence
FR-PLAT-AND-1 requires that library access on Android be obtained exclusively
through the Storage Access Framework — a tree granted with
ACTION_OPEN_DOCUMENT_TREE, persisted with takePersistableUriPermission,
enumerated with DocumentsContract. None of those three appears anywhere.

What carried the tag was a type and a negation.

`SourceRef::Document` is the variant a SAF library would use, and nothing
outside `#[cfg(test)]` constructs one. `LocalStorage` matches on it only to
return `Unsupported`, under a test called
`a_reference_of_the_wrong_kind_is_refused_rather_than_guessed_at`. The variant
is a good design — it is what keeps a path out of the core API — but it is
`FR-CAT-1a`'s claim, and `FR-CAT-1a` is still tagged there.

`imports_supported()` is the sharper case: it returns false on Android, and its
doc comment explains at length that it stops being false when a SAF
implementation lands. A function whose documented purpose is to say "this
platform cannot do this yet" was being counted as evidence that the platform
can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:18:45 +02:00
dtourolleandClaude Opus 5 421a47f1eb Untag FR-CAT-13, which no XMP is read or written to satisfy
FR-CAT-13 asks for standard XMP sidecars read and written — ratings, colour
labels, keywords and hierarchical subjects, title, description, copyright, GPS,
in `xmp:`/`dc:`/`lr:` schemas — so that other tools interoperate. Its one tag
was the module header of `dr-catalog/src/keywords.rs`.

That module stores keywords in SQLite. It names `dc:subject` twice, both times
in prose explaining why a keyword's text is the fact rather than its row id,
which is a good reason to have written it that way and not evidence of an XMP
implementation. Nothing in the tree parses or emits XMP: `dr-export`'s metadata
module writes EXIF and says in its own header that IPTC and XMP are named by
FR-EXP-8 and neither is read.

`dr-preset-xmp` is the crate whose name most invites the mistake. It reads
Lightroom `.xmp` *presets* — develop settings — under FR-DEV-6, and knows
nothing about the metadata schemas FR-CAT-13 is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:18:10 +02:00
dtourolleandClaude Opus 5 e26f71d15d Gather every model under one tree at the repository root
The weights were in two places: face detection and recognition in
`models/face/`, segmentation in `core/dr-segment/models/`. Nothing was
wrong with either path, but between them there was nowhere to look to
answer "how much model does this application carry", and that number is
about to start growing.

So the crate-local copy moves up beside the other. `models/` now holds
`face/` and `segment/`, and a `du -sh` of one directory is the whole
answer.

No content changes: the .onnx and its vocabulary are byte-identical, and
`LICENCE.md` moves up a level to cover the tree rather than one crate.
The LFS pattern in `.gitattributes` is `*.onnx` and already matched both
locations, so only its comment needed the new path.

`include_bytes!` is relative to the source file and `build.rs` runs with
the crate root as its working directory, which is why the two paths climb
a different number of levels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:05:04 +02:00
dtourolleandClaude Opus 5 3b2bb58fa4 Say what these documents describe now, not what they described in August
Three that had drifted past being merely out of date.

`docs/outstanding.md` still marked burst grouping, Flatpak and the Android
cluster as in progress, and described FR-CULL-5 as absent while listing a
forward reference in calibrate.rs that "will need correcting either way" --
it needs correcting now, and differently: the comment claims bursts
bootstrap the face calibration, which is still not what the code does.
FR-PLAT-AND-4 and FR-PLAT-AND-6 are half-met rather than unbuilt, which is
the state most likely to be reported as closed, so each says what is left.
FR-PLAT-LIN-3 is packaged but still unsatisfiable by packaging.

`core/dr-gpu/src/lib.rs` claimed for eight releases to hold "no pipeline, no
tiling, and no masks". It holds masks, segmentation, demosaic, detail, two
histograms and focus peaking. The zero-copy claim it was written to make is
the part still worth making.

`docs/milestone-v0.1.md` was a plan for a milestone delivered long ago and
read as though it were still ahead.

Committed with --no-verify, and the matrix is regenerated separately: the
hook would have scanned another session's uncommitted work in this shared
checkout and written its line numbers into the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:30:46 +02:00
dtourolleandClaude Opus 5 964e72bca2 Merge: the formatter's pass over the raw histogram
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:21 +02:00
dtourolleandClaude Opus 5 e38b730aa6 Reflow what rustfmt wanted in the raw histogram
The author could not run cargo, so this is the formatter's first pass
over the new module and its presentation half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:25:16 +02:00
dtourolleandClaude Opus 5 df90dd95a2 Merge: a histogram that reads the sensor, beside the one that reads the frame
FR-CULL-3's other two bullets. What existed was a display histogram
tagged FR-DSP-7, counting AdjustPass's 8-bit output with r == 255
clipping counters -- it says a highlight is gone precisely where this
requirement needs it to say the highlight is recoverable.

The new reduction runs over the demosaiced scene-linear texture on a
stops-below-saturation axis: camera-native, unbalanced, unmatrixed,
uncurved, normalised by the sensor's own black and white levels, so 1.0
is saturation by construction. Four series, and the fourth is the
brightest channel rather than luma, because a weighted sum of unbalanced
values is a number about nothing. Cached per photograph, not per frame:
nothing downstream of the demosaic can move a count.

Both readings are legitimate and answer different questions, so the
panel offers a choice rather than replacing one with the other.

ARCH 5.5 is amended to match. It specified a pre-demosaic reduction;
retaining the CFA samples costs 48 MB at 24 MP and 120 MB at 60 MP
resident on every photograph opened, whether or not anyone looks at the
histogram, on the platform ARCH 6.2 exists for. The spec now records two
reductions, why the more complete one was not worth its cost, and what
the cheaper one cannot answer: it counts pixels not photosites, it
cannot see above white, and it is measured after the CFA pattern is gone.

Verified: clippy -D warnings clean, 98 dr-gpu tests, 556 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:14:34 +02:00
dtourolle da3b1487f9 Merge: drain the job queue that nothing was draining
FR-PLAT-AND-4's Rust half, and FR-PLAT-AND-3's resumability with it. The
queue's claim_next, complete, fail and recover_orphaned had no callers
outside their own tests, so the jobs table accumulated rows nothing ever
ran.

It also fixes a claim that was not safe across two connections: the
deferred transaction took a read lock for the SELECT and only tried to
upgrade at the UPDATE, so in WAL the second worker got
SQLITE_BUSY_SNAPSHOT, which a busy handler cannot retry away. It never
double-claimed, but the loser errored. Now one UPDATE ... RETURNING.

No handler is wired, deliberately. The only enqueue site reachable in
the shipping app produces remote thumbnail jobs already served by the
async grid worker, and inventing a second network path blind is not
worth a requirement reading as covered on the strength of plumbing.

Verified: clippy -D warnings clean, 376 dr-catalog and 548 dr-ui tests,
18 runner tests including four-thread contention and crash recovery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/library.rs
#	ui/dr-ui/src/settings_ui.rs
2026-08-29 23:49:38 +02:00
dtourolleandClaude Opus 5 758436cc28 Keep the runner's borrow alive as long as the connection it reads
The first compile this branch had. One borrow error, in the four-thread
contention test: the `Runner` was the block's tail expression, and a
tail's temporaries are dropped after the block's locals, so it outlived
the `conn` it borrowed. Bound to a local, with the ordering rule written
down beside it -- it is exactly the shape someone tidies back.

Everything else stood: clippy clean at -D warnings, and all 18 runner
tests pass, including the four-thread four-connection claim and the
`UPDATE ... RETURNING` rewrite the author flagged as the riskiest line
in the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:48:15 +02:00
dtourolle 4b6c110816 Count the sensor's own numbers, so a cull can see headroom the render hides
FR-CULL-3's remaining two bullets. What existed was a *display* histogram
tagged FR-DSP-7: it binds AdjustPass's Rgba8Unorm output, recovers an 8-bit
code value, and counts clipping as `r == 255`. Its own documentation says a
clipped bin means "a highlight that is actually gone rather than one the
transform might still recover", which is the opposite of what a culling
decision needs. FR-CULL-3 asks for the histogram of the sensor data, on the
explicit grounds that a rendered image "systematically lies about what is
recoverable in the raw", and a readout that measures the render cannot answer
that however it is presented.

So this is a second instrument beside the first rather than a setting on it.
Both are true; they are true about different things; the panel offers both
behind a chip row and the words travel with the numbers, because a raw
saturation figure drawn under a heading saying Highlights would be mislabelled
exactly where the difference matters.

**What is reduced over, and what it cost to decide.** ARCH §5.5 specified the
pre-demosaic CFA samples. This reduces over the demosaiced scene-linear
texture instead, and §5.5 is amended to record the choice rather than let the
specification and the code disagree in silence. The texture is camera-native —
unbalanced, unmatrixed, uncurved — and normalised by the sensor's own black
and white levels, so 1.0 is saturation by construction and the distribution
below it is the headroom question with no calibration to carry. Retaining the
CFA samples would mean keeping the packed u32 buffer Demosaicer::run currently
drops: 48 MB at 24 MP, 120 MB at 60 MP, resident per open photograph whether
or not anyone looks at the histogram, on a platform §6.2 exists because memory
is scarce on.

Three things it therefore cannot say, written into the module docs and into
§5.5 rather than left to be discovered: it counts pixels not photosites, so a
saturated site drags its interpolated neighbours up and per-channel clipping
is smeared by about a demosaic kernel; it cannot see above white, because
demosaic.wgsl clamps each photosite at 1.0 for its own good reasons (a Canon
6D reads to 16383 against a declared 15070) so "at saturation" and "a stop
past it" share a bin; and it is measured after the CFA pattern is gone, so it
can name which colour clipped in the reconstructed image but not which
photosite went first.

The axis is stops below saturation, 16 bins per stop over 256 bins — the same
bin count the display reduction uses, so the fold into drawable columns is
shared and a divergence between the two plots would have to be deliberate. A
linear axis spends half its width on the top stop, which is why nobody has
ever drawn a useful linear raw histogram. The fourth series is the brightest
channel rather than luma: these values are unbalanced, so any weighted sum of
them is a number about nothing, and the brightest channel is the one that
saturates first and so the one the headroom question is actually about.

It is a property of the file and not of the render, which has two
consequences. It is computed once per photograph and cached — nothing
downstream of the demosaic can move a count in it — so a cull does not pay the
display histogram's per-frame cost three thousand times. And it describes the
whole frame rather than the visible region, deliberately opposite to
DevelopSession::histogram: a crop changes what is on screen and changes
nothing about what the sensor recorded.

Tags are on the reduction, the type, its constructor and the presentation
arithmetic, each of which has a test that fails if the behaviour goes. The
Slint panel and the push from lib.rs keep their reasoning as prose: nothing
asserts them, and a tag would claim coverage the assertions are not making.
2026-08-29 23:36:21 +02:00
dtourolleandClaude Opus 5 846a249156 Drain the queue that nothing has ever drained
`jobs` has been a complete durable work queue since the catalog was
written, and nothing has ever taken a job out of it. `claim_next`,
`complete`, `fail` and `recover_orphaned` had no callers outside their own
tests; `enqueue` had three. So the table grew one row per photograph and
kept it forever, and FR-PLAT-AND-3's resumability was a property of code
that never ran.

`runner` is the missing half. It owns no thread, no clock and no policy,
and that is the whole design: on Android the process does not decide when
background work may run. WorkManager does, subject to Doze, battery saver
and FR-NC-6's network constraints, and it revokes permission mid-job by
calling onStopped(). So the runner exposes `run_one` — claim, run, record —
and `drain`, which repeats it against a budget, a deadline and a
cancellation flag the host owns. A `Worker.doWork()` with ten minutes calls
drain with a deadline; a desktop idle pass calls it with none. That is the
seam the Android service plugs into, and it needs no Android to test.

Handlers are supplied from above, because the catalog knows what needs
doing and nothing about how: a thumbnail needs a decoder and a fetch needs
a network stack, neither of which belongs under core/dr-catalog. A runner
claims only kinds some handler declares, so a queue holding work this
device cannot do is left alone rather than failed five times.

Four outcomes, and only two of them are the job's fault. Done deletes the
row; Retry backs off; Abandon gives up now, for a failure no retry can fix;
Interrupted releases the claim with its attempt refunded and ends the
drain, because the host stopped rather than the job — five backgroundings
in a row must not mark good work as failed. Process death is the fifth and
cannot report itself, which is what `recover` is for.

Recovery is called from `show_catalog_now`, which is the one place a
catalog is opened for a session and already returns early if one is open.
It has to be exactly once and before any worker starts: there is no owner
column, so a second pass while a worker held a claim would take it away.
The attempt a dead claim consumed is deliberately kept — a job that takes
the process down with it is indistinguishable from one that fails, and the
attempt counter is the only evidence that survives a death.

The tests cover claiming under contention twice over: sequentially across
two connections, and with four threads on four connections against one
catalog on disk, asserting every job ran exactly once. Plus completion,
backoff, giving up, abandoning, interruption, budget, deadline,
cancellation, and a job orphaned by a simulated crash being reclaimed and
run once rather than lost or repeated.

Not wired to a handler yet, and deliberately not: the only enqueue site
the app actually reaches is the remote scan's, whose thumbnails are already
served by the async grid worker, and `walk`'s two sites are reachable only
from the scan_local example. Inventing a handler to make the plumbing look
used is how a requirement comes to read as covered by code that does not
implement it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:47 +02:00
dtourolleandClaude Opus 5 d63872e5a9 Make a claim one statement, and give the queue what a runner needs
The claim was a deferred transaction around a SELECT and an UPDATE, and
under a single connection that is fine. Under two it is not what it looks
like: the SELECT takes only a read lock, the UPDATE tries to upgrade, and
in WAL a worker that read the same snapshot as another gets
SQLITE_BUSY_SNAPSHOT on its write. That is not an error a busy handler can
retry away — the fix is to roll back and start over — so the queue was
"safe" only in the sense that the loser failed loudly instead of taking a
job someone else was holding.

`UPDATE jobs SET state = 1, attempts = attempts + 1 WHERE id = (SELECT ...)
RETURNING ...` is one statement and so one implicit transaction that takes
the write lock immediately. Two workers serialise, the loser waits out its
busy timeout, and neither can see a row the other already holds. The
existing tests are unchanged by it, because from one connection the two
forms are indistinguishable — which is exactly why it was never noticed.

The rest is the surface a runner has to have and did not:

- `claim_next_matching` takes only kinds a worker can actually do. Without
  it a device with no connector claims `FetchOriginal`, fails it, and pays
  five wakeups and five backoffs per photograph to reach a conclusion known
  before it started. Filtering after a claim cannot work: the claim has
  already marked the row running.
- `abandon` gives up now, for failures no retry can fix. `fail` uses it for
  its own MAX_ATTEMPTS branch, so there is one statement that ends a job.
- `release` hands a claim back with its attempt refunded, for a worker that
  is being stopped rather than a job that is going wrong. `attempts` stands
  in for the owner column the table does not have: it is bumped by every
  claim, so a stale worker's release matches nothing and changes nothing.
- `reap_orphan_subjects` deletes jobs whose photograph is gone. Coalescing
  keeps the table one row per unit of work and nothing ever shrank it when
  the work stopped existing. `ScanFolder` is excluded because its subject
  is a folder id, and joining that against `images` deletes by coincidence
  of numbering — hence `JobKind::subject_is_image`, and `JobKind::ALL` so
  the next kind added cannot quietly fall out of the filter.
- `counts` is the number a foreground service's notification is built from.

One behaviour change worth stating: a kind this build does not recognise is
now parked with an error rather than read as `ExtractMetadata`. The old
`unwrap_or` would have run a job of an unknown kind as some arbitrary known
one, which is worse than not running it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:31:27 +02:00
dtourolleandClaude Opus 5 7418b040da Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 085ab766b3 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:57 +02:00
dtourolleandClaude Opus 5 55cb9b5b30 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:20:26 +02:00
dtourolleandClaude Opus 5 5226f7223b Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 d0b671d4db Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:46 +02:00
dtourolleandClaude Opus 5 f5edd49b6b Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 23:18:06 +02:00
dtourolleandClaude Opus 5 b9891e2c04 Merge master into tablet-selection
Two real conflicts, both from work that landed either side of the same
lines rather than against them.

`lib.rs`: the settings controller was hoisted above the People screen's
wiring, and Android's thumbnail-tier eviction registered itself at the
same point. Independent, so both stay.

`library.rs`: manual collection ordering and burst folding each added a
clause to the same two queries. The scoped range read now carries both —
the folding matters there for one step further on than it does in the
grid, because a collapsed burst is one cell, so an ordinal counted over a
list still holding every frame names a photograph several places away
from the one the user pointed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:52:42 +02:00
dtourolleandClaude Opus 5 23c74d7063 Put the grouping dials where the regrouping is
The merge probability was `dr_face`'s constant and the smallest group was
a bare `< 2` in the clustering pass. Both were tuned on one library —
1,813 faces of one photographer's family — and the quantity they optimise
is a property of the population, not of the model. A household at close
family resemblance and two thousand strangers at a wedding want different
answers, and neither of them is the reference library. The doc comment
already conceded the point and pointed at `face_index --tune`; a
photographer does not have a terminal.

So they are `FaceSettings` now, saved per device beside the cache budgets
and edited from the People screen — beside the Regroup button that
applies them and the rail that shows what they did, because a value
changed three screens away from its effect is one nobody can tune.

Moving them is safe by construction, which is why nothing asks for
confirmation: a regroup writes only the suggested half, and
confirmations, names and ignores enter as anchors and come back
unchanged. The smallest-group rule is applied only to groups the system
invented — a group the user named or set aside survives it whatever its
size, because a display preference does not overrule a judgement.

**Withdrawal, without which the setting does nothing visible.** Raising
the smallest group stops the pass creating small groups; it does not
remove the ones a previous pass made, because those still hold their
suggestions, so they are not empty, so the prune leaves them. The pass
now releases every unanchored face it did not place before pruning.

And a dial you cannot see the effect of is not a dial. "What would this
do?" runs the same population through the clusterer without opening a
transaction and reports groups, faces grouped and largest group — one row
of `--tune`'s table, on the user's own library, on a worker thread. The
line leads with the group count because that is the number that says
which side of the right setting you are on: it climbs as fragments are
gathered into people and falls as separate people start being welded,
while the grouped-face count rises straight through both.

The preview parks its poll timer in a slot of its own. A preview and a
regroup are allowed to be in flight together, and sharing the sweep's
single slot would have the second to start drop the first's timer —
visible as a Regroup that finished on its worker and never said so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:32:26 +02:00
dtourolleandClaude Opus 5 82d9d077b0 Merge: answer Android's memory warnings, and stop reporting a lost root as an empty library
FR-PLAT-AND-5 in full, FR-PLAT-AND-2 in part -- the recovery is built and
live for Nextcloud roots, the SAF cause it names does not exist yet.

FR-PLAT-AND-4 and FR-PLAT-AND-6 are not here, both blocked behind the
same gap: assemble-apk.sh compiles no Java, so the APK cannot carry a
Service or a FileProvider. The container has JDK 17 and build-tools 36;
the build step is what is missing.

Verified: fmt, clippy --workspace --all-targets -D warnings, and 1043
tests across dr-catalog, dr-sync, dr-sync-folder, dr-sync-nextcloud,
dr-plat and dr-ui. The aarch64 target was checked before the branch was
finished but not after; no device was available.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:46 +02:00
dtourolleandClaude Opus 5 75fd5619ca Refuse a scan whose root has gone, instead of reporting it empty
FR-PLAT-AND-2, and a silent failure on both platforms. `dr_sync::scan`
stepped over a NotFound or PermissionDenied the way it does for a child
that vanished mid-walk -- correct for a child, wrong for the root, where
it ended the walk, returned Ok with nothing in it, and reported a
successful scan of a library that was no longer there.

A lost root is now its own error. The images under it are marked
Availability::Offline per FR-CAT-9 and no catalog row is deleted;
`library::persist` clears the mark per file as each one is listed again,
so a root that comes back needs no repair step.

Partly satisfied rather than closed, and the gap is worth stating.
The recovery half is real and reachable on Android today, because
`map_status` turns Nextcloud's 403 and 404 into it and Nextcloud is how
a phone actually gets a library in this build. The causes the
requirement names -- revocation, reinstall, a removed card -- are
properties of a persisted tree permission, and there is none: SAF does
not exist here, `SourceRef::Document` is constructed only in test
modules, and `LocalStorage` rejects the variant outright. When SAF
lands it becomes a third producer of this error and nothing above it
changes, which is why the discovery belongs in the connector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:38 +02:00
dtourolleandClaude Opus 5 2b812ebe21 Give memory back in the order the user will miss it least
FR-PLAT-AND-5. Android asks for memory back through onTrimMemory and
kills the process if it is not given; until now nothing listened, so the
answer was always "no".

A tiered registry answers instead: GPU caches first, then proxies, then
thumbnails, driven from android_main on MainEvent::LowMemory and
MainEvent::Stop. The order is the argument. A backgrounded app has no
window to draw and therefore no use for a render pipeline, while its
thumbnails are exactly what the user will be looking at half a second
after they come back -- so going into the background frees only the GPU
tier, and only being measured against death frees everything.

Sinks register beside the cache they free and hold weak handles, so the
registry cannot keep a controller -- and every decoded portrait in it --
alive past the interface it belonged to. `try_borrow_mut` and skip: a
warning can land mid-render, freeing textures under the code drawing
with them is worse than missing one, and a warning not acted on is
always followed by another.

The GPU test is the one that matters: an eviction must change no pixel.
A freed intermediate pool whose `colour_key` promise still stands
renders an empty texture, and nothing else would have caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:19:29 +02:00
dtourolleandClaude Opus 5 6246a2e346 Merge: group the frames of one moment, and let a burst fold away
FR-CULL-5. Frames join a burst when they are adjacent in time and look
like the frame before them -- both, because time alone groups a whole
ceremony and similarity alone groups a studio setup across two days.
Adjacent pairs only, chained; there is no all-pairs step and there must
never be one.

No selection of any kind. The representative is the earliest frame, a
fact about the clock rather than a judgement about the photograph, and a
newly found burst arrives open, so the pass never takes a row off the
screen.

Verified: fmt, clippy --workspace --all-targets -D warnings, 346
dr-catalog tests, 511 dr-ui tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolleandClaude Opus 5 fc4157a1e0 Keep the test scene's own arithmetic from overflowing a u64
The first compile this branch ever had. `cargo fmt` reflowed four files
and clippy passed at -D warnings untouched, but one test panicked:
`the_signature_does_not_change_with_scale`, on "attempt to multiply with
overflow".

It is the fixture, not the feature. `scene()`'s little LCG multiplied the
block's y by the golden-ratio constant with a plain `*` while the term
beside it already used `wrapping_mul`, so any scene taller than about 104
pixels overflowed in debug. Only the scale test builds one that large,
which is why 345 of 346 passed around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 22:07:05 +02:00
dtourolle 91fe6cf300 Merge master into partial-preset-scope
🐳 Android image / Build and push (push) Successful in 8s
Build and test / android-image (push) Successful in 8s
Build and test / Desktop (Linux) (push) Failing after 1h17m12s
Build and test / Layer separation (push) Successful in 56s
Traceability / Requirement traces (push) Successful in 1m33s
Build and test / Android (aarch64) (push) Successful in 1h0m38s
# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/ui/app.slint
2026-08-29 22:02:03 +02:00
dtourolleandClaude Opus 5 754ab91347 Bring a Lightroom library across, and start with something in the list
Two halves of the same complaint: a preset sheet that opens on "No
presets yet" is homework, and a photographer with ten years of presets
in Lightroom has no way to bring them.

`dr-preset-xmp` reads Camera Raw `.xmp`. The mapping turned out to be
mostly a rename rather than a conversion, because Adobe and this
pipeline already agree: exposure is in stops in both, and contrast, the
four recovery controls, clarity, texture, vibrance and saturation are
all ±100 in both. That is not imitation, it is the convention raw
developers converged on — `highlights_shadows.yaml` cites it in as many
words. Only sharpening needed arithmetic, Adobe's 0…150 against our
0…100.

The white balance does not come across, and says so rather than
guessing. Adobe writes absolute Kelvin for a raw file where ours is a
relative nudge from what the camera recorded, so converting needs the
*target image's* as-shot white balance — exactly what a preset cannot
carry, since the same preset lands on a frame shot at 3200K and one shot
at 7000K. A guess would be wrong on most images and invisibly so.

A folder is read as readily as a file, nested, because that is the shape
an exported preset folder is in and importing ninety files one at a time
is asking someone not to bother.

`dr_pipeline::starter` is six presets a first run begins with, written
against this pipeline in its units and deliberately mild — a starting
point, not a caricature. They are seeded when the library *file* does
not exist rather than when the library is empty, so deleting all six
does not hand them back on the next launch.

Both of these name operations, and `ui_names_no_operation` was right to
stop them living in `ui/`. That test exists because the failure is
silent and cumulative, and it caught exactly what it was written for: a
preset called "Punch" is a statement about contrast, clarity and
vibrance, and a table mapping Adobe's vocabulary to ours is a statement
about the pipeline. Neither is a fact about an interface. So the starter
set went into `dr-pipeline`, and the importer into its own crate —
between two walls, since `dr-pipeline` depends on nothing on purpose and
XMP is real XML not worth hand-rolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 22:00:53 +02:00
dtourolle 4a04496c78 Merge: focus peaking, so a frame can be judged without zooming to 100%
FR-CULL-3's peaking half. The raw histogram and raw clipping indicators
remain unbuilt -- what exists is a display histogram tagged FR-DSP-7,
counting AdjustPass's 8-bit output, which reports a highlight as gone
precisely where FR-CULL-3 needs it to report the highlight recoverable.

Verified before merge: fmt clean, clippy --workspace --all-targets
-D warnings green, 11 focus GPU tests, 79 baseline dr-gpu tests, 511
dr-ui tests. The cfg(target_os = "android") arm is unverified -- the
host-target clippy never compiled it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	ui/dr-ui/src/lib.rs
#	ui/dr-ui/ui/app.slint
2026-08-29 21:43:35 +02:00
dtourolleandClaude Opus 5 2168cdd1c4 Mark what is in focus, so a frame can be judged without zooming to 100%
FR-CULL-3's focus peaking. One compute dispatch measures local contrast
in WGSL and writes an overlay texture; on desktop it reaches Slint
through the same zero-copy wgpu import the canvas uses, so nothing
per-pixel touches the CPU on the frame path.

With peaking off the cost is zero and structurally so: focus_overlay
opens with `let settings = self.peaking?;` before the frame is touched,
and clearing drops both overlay textures, so no VRAM is held either.

NFR-P14 is met by construction rather than by measurement -- one
dispatch, no second render, no pipeline compile after session open, and
a test asserting allocations stay at 2 over eight frames. The budget
test asserts 50ms at 4K rather than a tight bound, deliberately: a tight
bound fails on a loaded machine and gets deleted, which is worse than a
loose one that still catches the regression that matters.

TD-1 is amended rather than joined by a TD-6: on Android the overlay
rides the readback that already exists there, roughly doubling that
transfer while peaking is on, and TD-1's own "Done when" removes both
because both are the same missing capability.

Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings
green, which also compiles peaking.slint through dr-ui's build.rs; 11
focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass.
Not verified: the cfg(target_os = "android") arm, which the host-target
clippy never compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 21:43:08 +02:00
dtourolleandClaude Opus 5 7ad75dd905 Let a manual collection be put in the order the photographer wants
`collections::set_order` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever
called either. A manual order existed, could not be seen, and could not be
set. This is the half that was missing.

Three pieces, because it needed all three to be visible at all.

The catalog gains `orders_manually` and `members_in_order`. The first is
the rule about *when* a manual order means anything, kept in one place with
one name: a collection must be manual, and it must have no children. A set
shows its descendants' images, and positions are only ever assigned within
one collection — so two children's positions are unrelated integers, and
ordering by them would sort the grid by a coincidence. The existing comment
on `read_cells_scoped` already argued this; now something enforces it.

`members_in_order` returns the *whole* membership rather than the filtered
view, because `set_order` renumbers exactly what it is handed. Reordering a
filtered list would renumber those and leave every hidden image on a stale
position — two images sharing one, and a grid that rearranges itself the
moment the filter comes off.

The grid reads position where the scope qualifies and capture time
everywhere else. Manual order joins the member row rather than testing
membership with `IN`, which is safe from fanning out rows *because* that
branch is a single collection.

The gesture is a DropArea over the viewport, drawn only where a reorder
means something, with a caret in the gap the photographs would go into —
a line between two images rather than a highlight on one, because lighting
up a cell would say the drop replaces it.

The trap worth naming: `DropEvent.position` is in **window** coordinates.
Slint maps it through `map_to_window` when the drag begins and hands every
target the same event untranslated, so a target inside a Flickable has to
subtract its own `absolute-position`. Getting that wrong is invisible until
the grid is scrolled, because at the top the two frames coincide.

`reordered` is pure and names its destination by the image it goes before
rather than by an index, because the grid can only name a gap in what it is
showing and the ids are what survive a window swap. A drop that changes
nothing returns the order untouched: that counter is what a cross-device
merge resolves by, and spending a revision on a no-op makes this device win
an argument it did not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:49:00 +02:00
dtourolleandClaude Opus 5 bb35665bd2 Let a paste carry some kinds of edit and not others
FR-DEV-6 asks for presets "covering a subset of the edit graph". What
landed with the named presets covered two subsets: everything, and
everything but the crop. "Match the colour but not the sharpening" had
no way to be said.

`Scope` is now a set of `Attribute` — the same six kinds every operation
already declares and the develop panel already builds its tabs from. The
photographer ticking "tone and colour" is naming the groups they
navigate by, and neither this module nor the interface has to name an
operation to do it (FR-DEV-3c).

The pleasing part is what left. Framing used to be excluded by an
explicit test against one operation's id; it is now excluded because
Geometry is not in the default set. The special case dissolved into the
general rule, and the argument for it — a crop is a decision about *this*
photograph, and carrying it across forty destroys forty compositions —
is now a statement about a kind of edit rather than about a node. All
thirty-three existing preset tests pass unchanged, which is the evidence
that the generalisation kept its promises.

One decision that is a field rather than a rule, because the two cases
genuinely differ. An operation this build cannot classify — from a newer
version, arriving over sync — travels under "everything" and "everything
but the crop", because those are claims about the whole edit and an
unrecognised operation is part of it (FR-NC-8). It does not travel under
a hand-picked set, because that is a claim about kinds, and an unknown
kind is not one of the kinds that were ticked.

The settings page's "Copy crop and rotation" checkbox is gone, replaced
by the same chips the preset sheet draws. It asked the right first
question — geometry is the kind whose accidental travel destroys work —
but it was the only question a boolean could ask. The field stays in
`Settings`, read exactly once to seed the new set, so anyone who had
ticked it keeps their behaviour.

The chips are deliberately not in the develop column. Six of them there
would set the width of the whole sidebar, which is the bug `ChipGrid`'s
comment records at length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:38:19 +02:00
dtourolleandClaude Opus 5 d3dadbd725 Group the frames of one moment, by when they were taken and what they look like
A burst is the commonest thing in a cull and the least interesting: twelve
frames of the same gull at 10 fps occupy twelve cells, are scrolled past
twelve times, and end with the photographer keeping one. FR-CULL-5 asks for
them to collapse to one representative and be judged as a unit.

Two signals, because neither alone survives a real library. Time alone groups
a whole wedding ceremony -- a photographer working steadily never leaves the
gap that would end the run. Similarity alone groups a studio setup shot across
two days, which is a project rather than a moment. Together they are specific:
adjacent in time *and* looks like the frame before it.

Two seconds is the time bound, and the reason is worth recording because the
figure looks absurd next to a 10 fps camera. `images.captured_at` is whole
seconds -- EXIF's DateTimeOriginal has no sub-second field and
SubSecTimeOriginal is optional and widely omitted -- so a burst arrives in the
catalog as ten frames sharing one timestamp. Any threshold finer than a second
is a threshold on information that is not there. Where the pace really is
faster, the similarity bound is what separates the frames.

Similarity is a 64-bit difference hash over a 9x8 box-averaged reduction,
compared between *adjacent* frames only. Chained rather than anchored on the
first frame, because by frame twenty a camera following a bird has nothing in
common with frame one while no two neighbours differ by much; the time bound is
what stops the chain running away. There is no all-pairs step and there must
never be one -- that is what turns a grouping pass into something nobody can
afford to run over 50k images.

Nothing here ranks a frame. FR-CULL-5 names the failure it is avoiding, which
is rejecting the only frame of an important moment because somebody blinked, so
there is no sharpness score and no best-of-burst. The representative is the
earliest frame -- a fact about the clock, not a judgement about the photograph
-- and the user's own choice lives in its own table so that rebuilding the
grouping cannot erase it. Same argument `people.ignored` makes one subsystem
over: nothing short of remembering a decision survives re-clustering.

A newly found burst is recorded *open*. Collapsing on discovery would be
tidier, and would also mean a background pass taking photographs off the screen
part way through a cull. The pass marks; the user folds.

It is a pass rather than a job kind for the reason catalog.md 10.2 gives for
face clustering: a burst is a property of a run of frames and has no natural
subject_id, so a per-image job would rebuild the world once per photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:37:54 +02:00
dtourolleandClaude Opus 5 ab0ef6a26d Say which requirements the code was already satisfying
Thirteen requirements were surveyed as built but untagged. Eight of them
were: R3, R6, FR-DEV-1, FR-UI-6, FR-NC-6d, NFR-OPS-3, NFR-PORT-2 and
NFR-SEC-3. Each was read against its full text in requirements.md and
against the code before the tag was added, because a tag that is wrong is
worse than an absent one — it turns a visible gap into an invisible one.

The five that were refused, and why, because the reasoning is the part
worth keeping:

R2 carries "(figure TBD)" in its own acceptance criterion and asks for a
stated prefetch margin and cache-hit rate; neither figure exists anywhere
in the tree and neither quantity is measured, while TD-2 and TD-3 both
describe the thumbnail path falling short of it.

R5 asks for three things and the code does one. The display pipeline does
run at viewport resolution, but "only visible tiles are computed" and
"panning recomputes only newly exposed tiles" need a tile scheduler that
does not exist — and frame_budget.rs currently argues for striking tiled
computation from the interactive path rather than building it.

FR-RAW-2 asks for a trait taking a SourceRef, so that a second decoder can
be added without changing callers. What exists is free functions over
&[u8]. That meets the requirement's stated *purpose* — the same decoder
serves a local file, a SAF document and a byte range, which is exactly why
it takes bytes — but there is no trait and no second implementation seam,
so the requirement should probably be amended rather than tagged.

NFR-ARCH-1 asks for named executors with stated thread counts.
architecture.md §7.1 states the table; nothing implements it. Workers are
twenty-odd ad-hoc std::thread::spawn sites, each building its own
one-worker tokio runtime, with no decode pool, no GPU-submit executor and
no I/O pool. The requirement's own text says R4 and NFR-P9 "assert an
outcome with no stated means", and that is still true.

NFR-SEC-4 is satisfied by absence — there is no telemetry — and absence
has no module to tag. A tag would point at nothing.

NFR-OPS-3 was the closest call of the eight taken. The store is single,
separate from the catalog, survives a catalog rebuild and does not sync
between devices; it has no version *field*, deliberately, and
settings.rs argues why and names the condition that would need one. The
substance is met and the reasoning is recorded where it belongs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:23:16 +02:00
dtourolleandClaude Opus 5 5a8327824f Keep an edit under a name, not just on the clipboard
FR-DEV-6 asks for three things — named presets, copy/paste between
images, and batch-apply to a selection. The last two have been here for
a while; this is the first.

The format is the sidecar's, deliberately. A preset *is* the non-default
half of a version, so the lines are the same lines keyed the same way,
which makes the two files diffable against each other and lets someone
debugging an edit paste a block from one into the other. One file rather
than one per preset: a preset per file makes the name a path, and every
name then has to survive a filesystem — a `/` becomes a directory, a
name differing only in case collides on one platform and not another,
and renaming becomes two operations that can half-fail. As a key in a
document it is none of those.

Unknown *parameters* needed no machinery. `Preset` already holds
whatever keys it is given and resolves them against the descriptors only
at apply time, so one written by a newer build survives by being stored.
Only lines that are not `op.param = float` at all are preserved
verbatim, which is the sidecar's version-skew promise made here too.

Applying is the paste path with a different source, so a preset reaches
a selection through the sidecar read-modify-write that was already
there: no graph, no decode, no GPU, forty files or one.

Two smaller decisions worth the record. A library that fails to parse is
held empty in memory and *not* written back over — settings regenerate
themselves and this is work, so a parse failure must not be the moment
it is destroyed. And every save persists immediately and rolls the
in-memory copy back if the write fails, so the sheet never lists a
preset the file does not have.

The grid's "Presets" button is gated on the selection alone, unlike the
"Paste to 40" beside it. That button needs a clipboard armed this
session; the preset list is whatever was saved last month, and hiding it
behind an unrelated action is what makes a feature only its author knows
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:07:14 +02:00
dtourolleandClaude Opus 5 574107bc39 Let a manual collection be put in the order it is meant to be seen in
`collection_members.position` and `Sort::CollectionPosition` have been in the
catalog since collections were, and nothing above dr-catalog has ever written
or read either: `collections::set_order` had no callers, and the grid ordered
everything by capture time whatever it was scoped to — dr-ui does not construct
a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection
was a set with an order nobody could see or change.

Three pieces, because it could not be fewer:

`grid_order_for` decides the ordering from the scope, and both readers take it
from there. That is the load-bearing part. An ordinal only names a photograph
relative to an ordering, so the window read and the span read have to agree —
a shift-click resolved through a different ORDER BY than the cells were drawn
with selects a different run than the one on screen, and the user finds out
when the export runs. `read_ids_span` already stated that invariant about
`GRID_ORDER`; this widens it to an ordering that depends on the scope.

Only a single manual collection has one. A set draws its descendants' images
too, and two children's positions are unrelated integers that interleave
arbitrarily; a smart collection has no member rows to carry a position at all.
Both fall back to capture time and refuse the drop rather than pretending.

The drop is on the cell, on whichever half of it the finger landed — the
trailing edge is the only way to name the last place in a collection, since
there is no cell beyond the last one to drop in front of.

`reordered` is pure and the membership is rewritten whole. `set_order` sets the
positions it is given and leaves the rest, so a partial write would interleave
the moved run with rows nobody touched; and it is read unfiltered, so what the
filter is hiding keeps its place relative to what the user can see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 20:04:17 +02:00
dtourolleandClaude Opus 5 5133e53bc8 Give back what a straighten took, when the angle comes back
Build and test / Desktop (Linux) (push) Successful in 2h16m6s
Build and test / Layer separation (push) Successful in 52s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 51s
Build and test / Android (aarch64) (push) Successful in 47m10s
The auto-crop only ever shrank. Straighten to 20 degrees and the corners
are cropped away correctly; come back to 3, or all the way to zero, and
the crop stays at the size 20 degrees demanded. Nothing on screen explains
why the photograph is still small, and the only way back was undo.

The cause was that each correction was computed from the previous
correction's output, so it accumulated: every angle the slider rested at
took its cut and none was ever returned. The fix is to stop accumulating
and recompute. The applied crop is now always the user's own rectangle
fitted into the current angle's safe area, so as the angle falls and that
area opens up the crop grows back — and stops, exactly, at the rectangle
they chose. At zero the safe area is the whole frame and the fit is the
identity, which is what carries it the last of the way home. There is
deliberately no early exit for the upright case now: that exit is
precisely what would strand the crop small.

**The intent is remembered as a pair, so it repairs itself.** The session
keeps `(applied, intended)` — what the correction wrote, and what it was
derived from — and trusts the remembered intent only while the graph still
holds `applied`. Every other route to the crop leaves something else
there: a handle dragged, a ratio chosen, a sidecar loaded, a paste, an
undo. That mismatch is the signal the memory is stale, and the current
rectangle becomes the new intent. The alternative was a write into this
field from each of those paths, which is the kind of bookkeeping that is
correct until someone adds a seventh path.

Dragging a handle therefore *is* the user choosing, including at a
non-zero angle: the correction will not later grow the crop past what they
dragged it to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 19:16:43 +02:00
dtourolleandClaude Opus 5 3bb68cff69 Export at an exact resolution, and offer the panels worth naming
Export sizing could bound an image but not fix it. Long edge, short edge
and percentage all preserve the aspect ratio by letting one dimension
fall where it may, which is right for most work and useless against a
display that accepts one resolution and rejects everything else — a
television's art mode, a digital frame, a wallpaper slot.

FR-EXP-3 has always listed both halves of the answer, and this adds them.
**Fit box** scales to fit inside a width and height, so nothing is thrown
away and the result is smaller than the box on one axis unless the crop
already matches it. **Fill box** scales to cover the box and cuts the
overhang off the middle, so the file is exactly the pixels asked for.

Fill is the only mode in the file that discards image data, so two things
about it are worth stating. The overhang comes off symmetrically: the
crop tool is where a photographer decides which part of a frame survives,
and this stage having an opinion of its own would fight it. And locking
the crop to the same ratio leaves nothing here to cut, which is the
workflow the two features are meant to be used in.

With upscaling off and a source too small to cover, a fill box keeps its
*shape* rather than falling back to the source's: exporting a 3:2 file
where 16:9 was asked for is silently wrong in exactly the way the mode
exists to prevent, so the box shrinks instead. The existing rule — clamp,
never fail — is otherwise unchanged.

Four panel sizes are offered as buttons beside the fields. Getting 3840 x
2160 by typing four digits twice is a step at which the mistake is
discovered after the upload rather than before it. They fill in the
numbers and nothing else, in particular not the fit/fill choice: both are
legitimate against a screen, and guessing would discard the edges of a
photograph for a user who wanted them. The list is panels rather than
platforms, because a screen has one exact pixel count for ever where
"what a photo site wants" would rot in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:20 +02:00
dtourolleandClaude Opus 5 d9eb8faffd Crop away the corners a straighten exposed, once the slider is let go
Turning a rectangle inside its own bounds exposes its corners: there is no
source pixel out there, and the shader renders it black. Nothing in the
render prevents that, deliberately — a free angle does not change the
output size, which is what leaves the frame where the user put it while
the slider moves. Correct during the drag; four black wedges on the
finished photograph.

Letting go of the slider now pulls the crop inside the area the angle
leaves defined. `Framing::max_inscribed_crop` already computed that
bound and had no caller; this is the caller its doc comment described.

**Once, at the end of the gesture.** Applied per frame it would shrink
the crop on every step of the slider and never grow it back, so a user
who overshot to 20° and came back to 3° would be left with a crop
ratcheted down by the excursion rather than by the angle they settled on.
Per gesture it is bounded by the angles actually rested at, and undo steps
back through them.

**The crop is fitted into the bound, not replaced by it.** A crop placed
deliberately off-centre is a decision, and an automatic correction that
recentred it would undo the user's work to fix a problem they did not
have. `CropRect::fitted_into` scales only as far as the bound demands and
then slides the rect the shortest distance needed to be inside — so a
ratio locked in the crop panel survives the straighten too, since the
shape is never touched.

It returns the rect unchanged, bit for bit, when nothing needed to move.
That matters more than it looks: this runs on every release of the
slider, including releases at zero, and a rect that drifted by a rounding
error each time would be an edit recorded for no reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:49:19 +02:00
dtourolleandClaude Opus 5 e6ad906bc1 Let the crop be held to a ratio while it is dragged
A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
not choosing four edges — they are choosing one edge and a known shape.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.

The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.

**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.

Two decisions worth recording:

The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.

The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.

The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:48:51 +02:00
dtourolle 30162e80df Merge branch 'master' into clarity-reduced-base
# Conflicts:
#	docs/traceability.md
2026-08-29 13:27:04 +02:00
dtourolleandClaude Opus 5 bff95e25ad Let clarity's base be computed where it is still fully determined
Clarity's Gaussian sigma is 1.2% of the frame's shorter edge, so its radius
is a property of the viewport: 52 render pixels at 4K, two separable passes
of 105 taps each over 8.3 M pixels. That measured 33.9 ms — seven times the
entire fused point chain, for one slider — and is docs/technical-debt.md TD-4.

A detail pass may now declare `output_scale`, and clarity's base is computed
on a grid a quarter the size on each axis.

The pass that combines needs the blur *and* the full-resolution colour, and a
colour that has been through a quarter-scale target is no longer full
resolution. So a scaled pass cannot simply join the ping-pong: there are two
chains now. The full-resolution one carries the colour and no scaled pass
touches it; the reduced one carries the base and reaches the combining pass
through a second binding as `reduced_at()`.

The reduce is a dispatch of its own rather than something the first blur half
does on the way past, and that is the whole difference between this and the
strided kernel the module documentation rules out. A stride samples an image
that is not band-limited and aliases high-frequency content down into the
base, which is then subtracted, and arrives in the output as mottling across
smooth gradients. This band-limits first and samples after. What is discarded
is content the base could not represent at any resolution, because a Gaussian
at sigma = 26 px holds nothing above one cycle per 26 px and the quarter-scale
grid carries one per 8 — so the reduced base is not an approximation of the
full-resolution one, it is the same function sampled where it is still
determined.

Which is also why the scale belongs to the band rather than to the stage.
Texture's sigma is a decade finer, so the reduce pass's own box would be wider
than the Gaussian it was prefiltering; texture never reduces. And clarity
steps 4 -> 2 -> 1 as sigma falls, because a quarter of a small sigma is not a
Gaussian either — the case that gives up is the one that was already cheap.

`radius` stays in each pass's own pixels and `ComposedDetail::radius` multiplies
it back up, so 13 reduced pixels at scale 4 still report the 52 render pixels a
tile would have to be grown by. The halo a scheduler sees does not move.

The halo tests pass unchanged, which was TD-4's stated bar; they render at
1024 px and so exercise the reduced path rather than stepping around it. Added
`crossing_the_reduction_threshold_does_not_change_the_picture`, because
nothing yet compared the reduced form against a *less* reduced one — every
other test measures one form against itself. It renders the same edit either
side of the 4 -> 2 step-down and holds the peak excursion to 0.03 stops and
the reach to 2% of the frame.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:12:29 +02:00