Commit Graph
387 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 1b8b7998a2 Let a name hold a group together, the way a confirmation does
Build and test / Desktop (Linux) (push) Successful in 2h6m43s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 29s
Build and test / Android (aarch64) (push) Successful in 21m27s
Sixteen people called Catherine, fourteen of them holding no faces at all, and
her actual photographs split across the two that did. That is not a sync fault;
it is this function, and it has been quietly doing it on every Regroup.

Naming a cluster does not confirm its faces. They stay *suggestions* — and only
confirmed faces anchored here, so the next pass cut them loose, regrouped them
into a brand new person, and left the named one holding nothing.
`prune_empty_unnamed` will not clean that up, because it has a name. Name the new
group the same thing, and it happens again. Repeat over a few sessions and you
have sixteen of her.

A name is a judgement about *this group*, of exactly the kind FR-CULL-12 says
travels and inference does not — the same argument that already anchors a group
the user set aside. So all three kinds of ruling anchor now: confirmed, ignored,
and named.

It also fixes the quieter half of the same fault. A face indexed later that
matches a named person now merges *into* them, rather than arriving as a rival
group the user has to name all over again.

Two tests, and the first fails without the change — it reports "Catherine"
finishing the pass with zero faces while a fresh unnamed person holds the two
she was named for.

This does not retro-fit an existing library: the fourteen empty Catherines stay
until they are merged by hand, and the two holding faces are separate identities
that only the user can say are one person. What it stops is making more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 09:48:32 +02:00
dtourolleandClaude Opus 5 2fb3eb5d2d Upload a shard that was sealed after the server saw it
Build and test / Desktop (Linux) (push) Successful in 2h7m52s
Build and test / Layer separation (push) Successful in 57s
🐳 Android image / Build and push (push) Successful in 12s
Build and test / android-image (push) Successful in 13s
Traceability / Requirement traces (push) Successful in 57s
Build and test / Android (aarch64) (push) Successful in 20m58s
The tablet showed 442 of a person's 648 faces and could never catch up. Its
shard ledger said why: it had adopted the laptop's shard 0 at 2,711,552 bytes,
and that shard is 20,402,176 bytes on disk.

The upload skipped it. A sealed shard the server already had under that name was
taken to be "byte-identical by construction", so its mere presence was proof
enough and the size was never consulted. That premise is false, and this library
is the counter-example: an *open* shard is uploaded on every pass as it fills —
that is how a growing shard reaches the other devices — so the server ordinarily
holds a partial copy of a shard that is later completed and sealed. Sealing then
froze that partial copy in place for ever.

Nothing downstream could recover from it. The tablet's `has_adopted` check is
keyed on name *and* size and would have re-downloaded a changed shard gladly; it
was never offered one, because the sending side had stopped looking.

So the comparison is on size, sealed or not. The client id is in the name, so
nobody else can have written the file and size is a sound test. A sealed shard
whose size already matches is still skipped on the first comparison, which is
all the original cheapness was worth.

The thumbnail upload had the identical test and the identical hole, and is fixed
with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v0.8.0
2026-08-29 08:32:01 +02:00
dtourolleandClaude Opus 5 f9e331eed2 Let a test build be opened up, when asked
`adb shell run-as` refuses on a release build — "package not debuggable" — and
the app's private storage is then unreachable from the host. That storage is
where the face shards, the thumbnail store and the catalog live, so when a
device disagrees with the desktop about what it has synced there is no way to
find out which of them is right. An evening was spent guessing at exactly that.

`DARKROOM_DEBUGGABLE=1 ./docker/android/package.sh --install` now sets
`android:debuggable` through aapt2's `--debug-mode`, and nothing else changes.

Set through aapt2 rather than written into `AndroidManifest.xml` on purpose: the
flag then exists only for the build that asked for it, and a release build
cannot inherit it because somebody forgot to take it out again. A debuggable APK
lets any process on the device read this app's files, so it belongs on a test
tablet and nowhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 08:32:01 +02:00
dtourolleandClaude Opus 5 f41b3f6e8e Answer "what would the other device end up with" without the other device
Build and test / Desktop (Linux) (push) Successful in 2h7m52s
Build and test / Layer separation (push) Successful in 59s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Successful in 37s
Build and test / Android (aarch64) (push) Successful in 22m22s
A tablet showed 200 of a person's 611 faces after syncing, and the obvious
suspects — that suggestions deliberately do not travel, that the cross-device
face match was too strict — were both wrong. Finding that out meant reading a
catalog on a release-signed Android build, which cannot be done.

So this stands the second device up locally: an empty catalog, given the images
a scan would have found, the shards adopted into it exactly as a sync does, and
the real catalog merged in as the remote. Then it counts, per person, against
what the source holds.

    person                     source     here
    Catherine                     611      611
    Me                            242      242
    Ian                           219      219

Which settled it: the merge carries everything, and the shortfall was transfer —
shards that never finished arriving. Worth keeping, because "did the sync lose
this or has it not got here yet" is a question that will come up again, and
guessing at it cost most of an evening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 23:09:28 +02:00
dtourolleandClaude Opus 5 bb3b512b81 Merge: a face sync that can actually complete
Three faults between a laptop that had indexed a library and a tablet that only
ever received part of it.

The face store was the one part of the catalog still on the rollback journal at
synchronous=FULL — 21.3 ms a commit against the 0.05 ms everything else pays,
four commits per photograph, on the order of fourteen minutes of pure fsync for
ten thousand images. It now writes the way the catalog and the thumbnail store
do, with a checkpoint before upload so the file that goes to the server is
complete without its write-ahead log.

Every idle pass read 94 MB of shards to decide it had nothing to send; the name
and the size answer that.

And the 60-second total request timeout was a floor on link speed rather than a
hang detector: a 25 MB shard needed a sustained 425 KB/s or it failed, and then
retried and failed again indefinitely. It now times out on inactivity.

The merge itself was never at fault — a probe that stands up an empty catalog,
adopts the shards and merges the real one in gets all 611 of a person's faces,
not 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 23:08:29 +02:00
dtourolleandClaude Opus 5 d2af6a3981 Time out on a stalled transfer, not on a slow one
The HTTP client had a 60-second *total* request timeout. That is not a hang
detector; it is a floor on link speed. A face shard runs to 25 MB, so it
demanded a sustained 425 KB/s or the transfer failed — and having failed it was
retried on the next pass and failed again, for ever.

A tablet on ordinary wifi could therefore never finish taking in a library's
faces, and nothing said why: each attempt looked like a network blip rather than
an arithmetic impossibility. The catalog snapshot is 36 MB and has the same
problem.

`read_timeout` fires when no bytes arrive for the period, which is the condition
actually worth failing on. A slow transfer that is still moving now finishes,
however long it takes; a connection that has genuinely died is still caught in a
minute. The connect timeout is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 23:08:16 +02:00
dtourolleandClaude Opus 5 d70fe9da8d Decide whether to send a shard before reading it
The upload read every shard off disk and only then asked whether it needed
sending. For a library with 94 MB of face shards already on the server, every
idle sync read 94 MB to conclude it had nothing to do.

The question does not need the bytes. A sealed shard the server already has is
byte-identical by construction, and the client id is in the name, so nobody else
could have written it — the name settles it. The open shard is compared on size,
which `stat` answers.

The progress line moves after the skip for the same reason: announced before it,
an idle pass claimed to be sending five shards and sent none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 23:08:16 +02:00
dtourolleandClaude Opus 5 2c84aa1224 Write face shards the way the rest of the catalog writes
The face store was the one part of the catalog still on SQLite's default
rollback journal at `synchronous = FULL`. The catalog itself runs WAL at
`NORMAL` (`schema::configure`) and so does the thumbnail store; nothing decided
this one should differ, it was simply never set.

Measured on this project's own filesystem, that is **21.3 ms per commit against
0.05 ms** — four hundred times. And an export commits four times per
photograph: the shard's transaction, then three separate autocommitting writes
to the index. Ten thousand images is on the order of fourteen minutes spent
doing nothing but waiting for fsync, before a byte goes to the server. That is
the "checking faces…" that appeared to hang.

So: WAL and `synchronous = NORMAL`, matching the rest, and the three index
writes fold into one transaction. `NORMAL` is the same trade the catalog makes —
a shard is derived data, and losing the last commit to a power cut costs one
image re-exported.

WAL brings an obligation with it, because **a shard is uploaded by reading its
file**: the newest commits live in a `-wal` sidecar that no upload sends, so
without a checkpoint the server would receive a database missing exactly the
faces just written, and a peer would adopt it and see nothing wrong. `checkpoint`
folds the logs back in, with `TRUNCATE` rather than the default passive mode,
which gives up when a reader holds the log and would leave the same gap while
reporting success.

Two tests: that the store is in WAL like everything else, and — the one that
matters — that a checkpointed shard copied *without* its `-wal` still holds
every face. That second one fails without the checkpoint, which is how it was
confirmed to be testing something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 23:08:10 +02:00
dtourolleandClaude Opus 5 0bba882fb1 Read the Android API level out of the ELF, not out of file(1)
Build and test / Desktop (Linux) (push) Successful in 2h8m13s
Build and test / Layer separation (push) Successful in 1m3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 41s
Build and test / Android (aarch64) (push) Successful in 1h0m55s
Every push has failed the Android job for weeks — back through fourteen runs —
with "FAIL: linked for Android 'unknown', expected 28", on a .so that was linked
perfectly correctly at API 28 the whole time.

The check ran `file` on the linked object and pulled the level out of its
description with a regex. `file` only prints "for Android 28" when its magic
database is new enough to decode `.note.android.ident`, and this image's is not
— it stops at "dynamically linked, not stripped". The regex matched nothing, and
`${API:-unknown}` turned that silence into a confident-looking failure about the
artefact rather than about the tool inspecting it.

The API level is the first word of that note, little-endian, so it is read
straight out of the ELF with `readelf` — which is in the image via
build-essential, and cannot go out of date the way a magic database can. An
absent note is now its own message rather than being folded into the mismatch
case, since "nothing states an API level" and "states the wrong one" are
different faults.

`file` stays in the image and in the log: it names the NDK that built the
object, which is worth having when this does go wrong. Nothing depends on it.

Verified inside the real container against the linked .so: the note reads
1c 00 00 00, and the step prints "OK: linked for Android 28".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 22:28:57 +02:00
dtourolleandClaude Opus 5 e997be8c72 Say which version this is: 0.8.0
Build and test / Desktop (Linux) (push) Successful in 2h14m44s
Build and test / Layer separation (push) Successful in 54s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 1m45s
Build and test / Android (aarch64) (push) Failing after 53m50s
A hundred commits since 0.7.0, and the face subsystem in them is not a set of
fixes — it is the difference between a feature that was shipped and one that
works.

Clustering finished at all for the first time: the old agglomeration rescanned
every live pair and recomputed average link from scratch after every merge, and
on a real library it did not return. A sparse above-threshold graph, connected
components and Lance-Williams sums put 1,813 faces at 0.28s, and the merge
threshold moved to 0.80 because the old 0.90 was measured — not guessed — to
leave a third of the library ungrouped.

Faces are no longer indexed at any size or any sharpness. Both floors were
measured over the real library with `face_index --quality`: 32 source pixels
across the aligned crop, and a contrast-invariant sharpness that catches the
large-but-blurred face whose confident, wrong embedding used to weld two people
together.

The crop is cut once and kept, so the People screen is no longer a derivative of
a thumbnail cache entitled to evict anything at any moment. The screen itself
became usable: the faces wrap into a grid instead of running off the edge, the
header fits a phone, a group can be set aside, and a person's photographs are a
button away — as a union or an intersection of several people.

And face sync now reaches the other device. Re-indexed images re-export, shards
written before the crop and index-time columns are repaired rather than failing
every insert, people and the user's judgements about them cross the wire at all,
and the pass says what it is doing while it does it.

The Android versionCode follows without being restated — package.sh packs
MAJOR*10000 + MINOR*100 + PATCH, so 0.8.0 is 800, above the 700 already on
devices and therefore an upgrade rather than a refusal. `pkgrel` returns to 1,
since this is a new version rather than a rebuild of the last one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 22:24:58 +02:00
dtourolleandClaude Opus 5 a8ce1ff19c Merge: a face sync that says what it is doing
Build and test / Desktop (Linux) (push) Successful in 39m22s
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 5s
Traceability / Requirement traces (push) Successful in 53s
Build and test / Android (aarch64) (push) Failing after 53m19s
The face pass announced itself once and then went quiet for the whole export,
upload and adopt. On a first export after a re-index that is eight minutes of a
motionless progress bar, which reads as a hang. It now reports the images it is
preparing, the shards it is sending and their size, and the shards it is taking
in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:56:22 +02:00
dtourolleandClaude Opus 5 a1e8f494b9 Say what the face sync is doing while it does it
The face pass set the status to "checking faces…" once and then said nothing
until it was finished. On a library whose first export after a re-index is 9,849
photographs and 85 MB of shards, that is eight minutes of a progress bar sitting
still — which is indistinguishable from a hang, and was reported as one twice.

Nothing was wrong with the sync. The only fault was that it was silent.

Three places now report, which are the three that take real time:

- **Preparing**, per image with a count, since this is the long one and the only
  one whose length the user cannot guess from anything on screen.
- **Sending**, per shard with its size, because a face shard carries crops and
  runs to tens of megabytes — one of them is a visible wait on any connection.
  Announced before the upload rather than after, since the wait *is* the upload.
- **Taking in** a peer's shard, which is a download and then a row-by-row merge.

The export reports every 25 images rather than every one, so the channel behind
it stays lost in the write it accompanies. `export_to_shards` keeps its old
signature and delegates, so the callers that do not want progress do not grow a
parameter for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:56:16 +02:00
dtourolleandClaude Opus 5 35febf31dd Merge: a face sync that finishes
Build and test / Desktop (Linux) (push) Successful in 35m30s
Build and test / Layer separation (push) Successful in 53s
Traceability / Requirement traces (push) Successful in 38s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 58m37s
The export asked whether each image was already in the shard store at its
current index time, and answered by opening the shard database — schema batch,
pragma probes and all — once per photograph. 9,849 opens per sync pass for this
library, before any face was written, which showed up as a sync stuck on
"checking faces…" and never coming back.

The index time moves to the store's own index, which is already open, and the
write handle is held across a run instead of reopened per image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:20:11 +02:00
dtourolleandClaude Opus 5 0729dfa359 Stop the face sync opening a database per photograph
"Checking faces…" never finished. `export_to_shards` asks, for every indexed
image in the library, whether the shard store already holds that image at that
index time — and `indexed_at` answered by opening the shard database, running
its six-statement schema batch and two `pragma_table_info` queries, then
querying. Once per image. 9,849 times for this library, on every sync pass,
before a single face had been written.

The index time now lives in the store's `index.sqlite` alongside the shard
number, so the question is one indexed lookup on a connection that is already
open. It stays in the shard as well — that copy is the one that travels — but
nothing reads it from there on the hot path.

The write side had the same shape: `put_image` opened the shard afresh for each
image, which mattered little when exports were a handful of new photographs and
matters a great deal now that a re-index sends thousands. The handle is kept and
reused, invalidated by shard id so sealing a full one and moving to the next
drops it without anything having to remember to.

`INDEX_SCHEMA` is `CREATE ... IF NOT EXISTS` like the shard schema, so the new
column is added on open for an index already on disk — the same trap, caught the
same way.

Two tests: that the index time survives reopening the store, and that an index
written before the column can still be opened and written to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:19:56 +02:00
dtourolleandClaude Opus 5 9052b1a1de Merge: repair shards that predate the crop and index-time columns
Build and test / Desktop (Linux) (push) Successful in 33m10s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Successful in 38s
Build and test / Android (aarch64) (push) Failing after 52m58s
The schema batch is all CREATE IF NOT EXISTS, so two columns added to it never
reached any shard already on disk — and every export into one failed on "no
such column", quietly, logged at warn while the sync reported success. That,
rather than anything in the export logic, is why this library's shard stayed at
1,807 faces while the catalog reached 15,194.

Shards are now upgraded when opened, and a peer's read-only shard is read as it
stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:52:15 +02:00
dtourolleandClaude Opus 5 76eece8500 Add the columns an existing shard never got
`SHARD_SCHEMA` is entirely `CREATE ... IF NOT EXISTS`, which does exactly
nothing to a table that already exists. So `crop` and `indexed_at`, both added
to that batch, never appeared in any shard that had been written before — and
the `INSERT` naming them failed with "no such column".

Which took face export down completely, on every library that had ever synced a
face. Silently: `export_to_shards` returns the error, `sync_face_shards` logs it
at warn, and the sync goes on looking successful while the catalog fills with
faces no other device will ever see. This library's shard sat frozen at 1,807
faces with 15,194 in the catalog, and the reason was this rather than anything
in the export logic.

Shards are upgraded on open now: both columns are additive and nullable, so
catching up is one `ALTER` each. There is deliberately no version counter —
"does this column exist" is the question actually being asked, and asking it
directly cannot fall out of step the way a counter can.

A peer's shard is opened read-only and cannot be repaired, so one written before
crops is read as it stands, with a `NULL` standing in for the column. An adopted
face simply has no crop, which is the truth about it.

Four tests, built against the pre-crop schema written out in full rather than
derived from the current one — the point being that it is *not* the current
schema and must not track it. Verified against the real 1,807-face shard on this
machine: the ALTERs apply, writes succeed, and nothing already in it is lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:50:55 +02:00
dtourolleandClaude Opus 5 43097f5033 Merge: make face sync actually reach the other device
Build and test / Desktop (Linux) (push) Successful in 31m3s
Build and test / Layer separation (push) Successful in 36s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 30s
Build and test / Android (aarch64) (push) Failing after 50m38s
Two independent faults, both of which had to be fixed before a tablet could show
what a laptop had indexed.

An image was exported to the face shards exactly once, so a re-index updated the
catalog and nothing else — this library went from 1,807 faces to 15,194 and kept
syncing the original 1,807.

And people never crossed a device boundary at all: the catalog merge handled
collections and keywords only, so the far end received every face and no groups,
and drew an empty People screen over a full catalog. Names, confirmations,
rejections and set-aside groups now merge by uuid, with faces matched across
devices by photograph and box overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:27:28 +02:00
dtourolleandClaude Opus 5 4ed10f7b23 Let a person cross from one device to another
The face shards carry boxes, landmarks and embeddings. What they deliberately
do not carry is who anybody **is** — the person rows, their names, and the
assignments joining the two. Those travel in the catalog snapshot, which is a
whole-file copy and does contain them.

But the snapshot is *merged*, not adopted, and this merge only ever looked at
collections and keywords. `face_shard`'s own module note says people travel in
the snapshot; nothing implemented it. So a second device received every face and
no people at all, and drew an empty People screen over a full catalog. Exactly
what a tablet showed after syncing thousands of faces from a laptop.

What travels is what the user decided, following the rule the rest of this
module already follows — judgements travel, inference is rebuilt:

- **People**, by uuid on `revision`, exactly as a collection is: the name, and
  whether the group was set aside.
- **Confirmations**, and **rejections** — "this is not her" is a fact too, and
  is why re-clustering does not put it back.
- **The suggestions inside an ignored group**, which are otherwise ordinary
  inference but are what anchors the ignore. Without them a group set aside on
  one device reappears on the other, the same fault that made "Not interested"
  not stick locally.

Ordinary suggestions are not carried. Both devices hold the same embeddings and
clustering is deterministic, so each recomputes them and arrives at the same
answer; shipping them would double the merge for no new information.

**A face has no cross-device identity**, and unlike a collection there is no
uuid to give it one. Both devices do agree on `oc:fileid` and roughly on the
box, so a remote face is matched to the local face on the same photograph whose
box overlaps it most, above 0.5 IoU. That is not a new rule — it is the one
`record_detections` already uses to carry a confirmation across a re-index, and
it is loose on purpose: the question is "the same face in the frame", not "the
same rectangle".

A local confirmation is never overwritten. Two devices confirming one face as
different people is a real disagreement and an assignment carries no revision to
settle it with; taking the remote's answer would let a sync undo what the user
just did on the device in their hands.

The remote's schema is probed rather than assumed: `remote_is_mergeable` admits
any catalog at or below this version, so one written before faces existed, or
before V10 added `ignored`, is ordinary. An absent table skips this half instead
of aborting a merge that would otherwise have succeeded.

Nine tests, including that the name lands on the overlapping face and not its
neighbour in the same frame, that a set-aside group stays set aside, that an
ordinary suggestion does not travel, and that merging twice changes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:27:17 +02:00
dtourolleandClaude Opus 5 cf614efa61 Send a re-indexed image's faces to the other devices
`export_to_shards` asked `store.contains(file_id)` and skipped anything the
shard store had already heard of. So an image was exported exactly once, and
re-indexing it updated the catalog and nothing else — every other device kept
the first answer for ever.

That is not hypothetical. This library was re-indexed after the detection floors
changed and crops were added, going from 1,807 faces to 15,194; the shard store
still held the original 1,807, written before any of it. Nothing the re-index
produced could reach another device.

The shard's `indexed` table now carries the catalog's own `indexed_at`, and the
export compares against it. A re-indexed image goes again; an unchanged one
still costs nothing. Copied from the catalog rather than stamped when the shard
is written, because a shard-local write time advances even when nothing changed
and could not answer the question.

The column is nullable so a shard written before it still reads: absent means
"cannot vouch for it", which forces one re-export and then settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:27:16 +02:00
dtourolleandClaude Opus 5 b60f9d10d9 Merge: 32-pixel faces, a header that does not overlap, and set-aside that sticks
Build and test / Desktop (Linux) (push) Successful in 30m54s
Build and test / Layer separation (push) Successful in 44s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Traceability / Requirement traces (push) Successful in 43s
Build and test / Android (aarch64) (push) Failing after 52m25s
The size floor comes down to 32 source pixels with the blur floor moved to match
— they are coupled, since an upsampled face scores low on sharpness whatever its
original quality, and dropping one without the other would have undone itself.

The Identity header stops pinning its rows shorter than the controls in them, so
the name field no longer draws through the buttons below it.

And a group set aside now survives Regroup: its faces anchor the way
confirmations do, so they stay where the user put them instead of regrouping
into a fresh person with no ignore flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:30:32 +02:00
dtourolleandClaude Opus 5 41daa4cca7 Keep a group set aside actually set aside
"Not interested" hid a group, and the next Regroup brought it straight back.

Reclustering anchors the faces the user has ruled on so a pass cannot move them.
It took *confirmations* as the only kind of ruling — but setting a group aside is
a ruling too, and the faces it covers are only ever suggestions. So an ignored
group's faces entered clustering loose, regrouped into a fresh person that
carried no ignore flag, and reappeared in the rail. The original group was left
behind holding nothing, hidden and empty.

Anchoring them on `ignored` as well as on `confirmed` fixes it, and does one
better: a face indexed later that matches a group which was set aside now merges
*into* it, so a stranger photographed again stays set aside instead of arriving
as somebody new. That is the case that would otherwise have made the feature
feel like it only half worked.

Three tests, and the first fails without the change — it reports the group
coming back with its two faces while the original sits ignored and empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:30:16 +02:00
dtourolleandClaude Opus 5 c81d6865a7 Stop the name field running through the buttons under it
The Identity header was two rows pinned to 32px and 36px. Neither number was big
enough for what the row held: a `Field` is `Theme.touch-target` — 44px — and a
`Button` is `Theme.control-height`.

Slint honours a child's own height and lets it overflow the box the layout gave
it, so the name field drew 44px from the top of a 32px row while the button
strip began at 38px. The overlap was 6px of text box sitting on top of "Confirm
all".

Neither row states a height any more. The first takes the height of what is in
it, and the strip takes the height of a control — read from the theme rather
than from the row inside it, since `actions` sizes itself from the Flickable's
viewport and measuring it back would be a binding loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:30:15 +02:00
dtourolleandClaude Opus 5 1d4c3348af Let a 32-pixel face count, and move the blur floor with it
64 source pixels was too strict: it threw away 70% of everything the detector
finds, and plenty of what it took were faces a person could name.

Lowering it is not a one-line change, because the two floors are coupled. A face
under 112 pixels is *upsampled* to reach the embedder and upsampling invents no
edges, so a small face scores low on sharpness however crisp the original was.
Re-measured over the reference library with `face_index --quality`:

    min crop   min sharp   size cut   blur cut       kept
          32       0.000        44%         0%        56%
          32       0.002        44%         3%        53%
          32       0.005        44%         8%        48%
          32       0.010        44%        16%        40%
          32       0.020        44%        27%        30%
          64       0.020        70%         7%        23%

Holding the blur floor at 0.020 while dropping the size floor to 32 would have
rejected a further 27% — for being small rather than for being blurred — and
kept only 30%, barely more than the 23% the strict pair kept. Most of the point
of lowering the size floor would have gone straight back out through the other
gate.

0.005 removes 8% of what the size floor leaves, which is the same job 0.020 was
doing at 64 (7%): the large-but-soft face this gate exists for. Together they
now keep 48% of what the detector finds, against 23% before.

The box pre-filter follows down to 24, staying below what the real floor accepts
so it cannot reject a face that would have cleared 32.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:30:09 +02:00
dtourolleandClaude Opus 5 c7cdf7e5f0 Merge: face quality gates, and identity filters that combine
Build and test / Desktop (Linux) (push) Successful in 32m56s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Successful in 39s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 52m43s
Two things the People screen was missing.

Faces were being indexed at any size and any sharpness — 40 pixels on the box
and no blur gate at all — so most of what the library held was background
strangers and motion-blurred passers-by, and the blurred ones were quietly
bridging unrelated clusters. Both floors are now measured on the real library
with `face_index --quality` rather than guessed: 64 source pixels across the
aligned crop, and a contrast-invariant sharpness of 0.020.

And the grid could only ever be narrowed to one person, which cannot express
"the pictures the two of them are in together". The filter now holds a set with
a union/intersection mode, built a person at a time from the Identity screen and
taken apart chip by chip on the filter bar.

fmt, clippy -D warnings and the full workspace suite pass on the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:23:37 +02:00
dtourolleandClaude Opus 5 af5a13b3f7 Narrow the grid to several people at once, either way
"Show photos" could only ever mean one person. The two questions a photographer
actually asks are "every picture of Anna or Bob" and "the pictures they are both
in", and the second is not reachable by any sequence of single-person filters —
no amount of switching between one person and another finds the frame they
share.

So the filter holds a *set* of people and a mode. `RatingFilter` was already the
right home, as its own doc says: every query path threads it, so the count in
the header and the cells in the grid are narrowed by the same thing, and this
composes with stars, flags and the date range for free.

The union is `EXISTS ... person_id IN (...)`. The intersection counts
**distinct** people per image and compares against the size of the selection —
one subquery rather than one per person, and it does not grow the statement with
the selection. `DISTINCT` is what makes it correct: three faces of Anna in one
frame must not satisfy a filter asking for Anna and Bob, and there is a test
that says so.

Any rather than All is the default. With one person the modes are the same
filter, and adding a second to a union can only ever show more — so a user who
has not noticed the toggle never ends up staring at an empty grid wondering what
they broke. The toggle only appears at two people, because a control that
demonstrably does nothing is a control that teaches the user to ignore it.

Building the set needs no picker of its own: the Identity screen gains "And
also…" beside "Show photos", offered only once the grid is already narrowed to
somebody. Each person is a chip on the filter bar and each chip removes just
that person, so a selection of three can be taken apart one at a time rather
than only cleared wholesale.

`RatingFilter` stops being `Copy`, since it now holds a `Vec`. Every query path
already took it by reference; the casualties were two struct updates and one
`Cell` that becomes a `RefCell`.

484 dr-ui tests pass, including the union, the intersection, that one person
reads the same in both modes, and the repeated-faces trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:17:59 +02:00
dtourolleandClaude Opus 5 a1790c3e67 Stop indexing faces too small or too blurred to be anyone
The library was storing faces at 52 source pixels and embedding whatever came
back. There was a size floor, but it was 40 pixels on the *bounding box*, and
there was no blur gate at all — so a subject walking through a half-second
exposure detected confidently, aligned cleanly, and produced a perfectly
ordinary-looking 512-vector. Nothing downstream can tell that apart from a real
face, and because blurs resemble each other more than they resemble the people
they were, they cluster together and weld unrelated identities into one group.

Two floors, both measured rather than guessed. `face_index --quality` runs the
detector over real proxies with both gates disabled and prints the distribution;
over 1,503 faces in 600 images of the reference library:

   percentile   crop px   sharpness
           1%        16      0.0006
          25%        23      0.0025
          50%        38      0.0071
          75%        76      0.0284
          99%       352      0.4282

The median face in a personal library is 38 pixels. Most of what the detector
finds is background: people across a square, a face on a poster, a stranger at
the next table. They are real detections and useless identifications.

**Size, on the crop rather than the box.** "At least 64x64" has to mean the
pixels the *embedder* sees, and the box is not that — the ArcFace template
reaches past it for forehead and chin, so the aligned crop spans roughly 1.3x
the box's shorter edge. The floor is therefore `min_source_px` on the aligned
crop, applied after the warp fixes the scale, and `min_face_px` drops to 48 as
what it always really was: a cheap pre-filter set low enough that it cannot
reject a face the real floor would have kept.

**Sharpness.** Variance of the Laplacian divided by the variance of the luma it
was taken over. The division is the part that matters: raw Laplacian variance
scales with contrast, so a threshold on it would quietly discard every backlit
portrait in the library. The ratio asks how much of the crop's variation is
edges rather than broad gradients, and is invariant to exposure.

What each pair removes, cumulatively, of everything the detector finds:

    min crop   min sharp   size cut   blur cut       kept
          64       0.000        70%         0%        30%
          64       0.010        70%         3%        27%
          64       0.020        70%         7%        23%
          80       0.010        76%         2%        21%

64 and 0.020. The size floor does most of the work, and the blur floor removing
only 7% on top of it is the point rather than a disappointment: at 64 pixels
most faces are already sharp, and what it takes out is the large-but-soft one —
precisely the face that would otherwise contribute a confident, wrong embedding.

The two gates are not independent and the doc comments say so: a face under 112
pixels was upsampled to reach the embedder, and upsampling invents no edges, so
small faces score low on sharpness even when the original was crisp. That is why
`--quality` prints them together.

**This will re-index.** Around 70% of what the current settings store falls below
the new floors — faces between 20 and 40 pixels that nobody could identify. The
People screen gets shorter and every group in it gets better.

66 dr-face tests pass, including that a blurred crop scores below a sharp one,
that halving the contrast does not move the score, and that an upsampled face
scores below the same face at full size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:17:38 +02:00
dtourolle 329c388d30 Merge: each canvas overlay goes home to its own domain
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Desktop (Linux) (push) Successful in 1h23m17s
Build and test / Layer separation (push) Successful in 41s
Traceability / Requirement traces (push) Successful in 25s
Build and test / Android (aarch64) (push) Failing after 52m22s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
2026-08-27 21:43:31 +02:00
dtourolleandClaude Opus 5 7d23fe8683 Merge: the develop view's chrome gets its own file
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:35:23 +02:00
dtourolleandClaude Opus 5 4fa914cbb1 Send each canvas overlay home to its own domain
The crop rectangle, the gradient handles and the repair discs were 480
lines inside `canvas-area` in app.slint, while the panels that drive them
already lived in adjust.slint, masks.slint and spots.slint. spots.slint
even opens by describing "what is drawn over the photograph, and what a
finger can take hold of" -- which was not in it.

They stayed behind because all three are positioned against `shown-*`,
the fitted image rect the develop view derives because Slint does not
report it. That is now the interface rather than the obstacle: each
overlay is *given* that rect as its own bounds, so every position inside
is a plain fraction of `root.width`, and none of them reaches out to
`canvas-area` for an origin any more.

GradientHandles joins MaskPanel in masks.slint and SpotHandles joins
SpotPanel in spots.slint, each file now holding one domain's panel and
its canvas overlay together, matching masks_ui.rs and spots_ui.rs.
CropOverlay gets crop.slint of its own rather than growing adjust.slint.

Arithmetic is unchanged: the old fraction-x subtracted shown-x from a
coordinate measured relative to canvas-area, and the new one measures
from an origin that already is shown-x. The handles stay unconditional
rather than gaining an emptiness guard, so the repeater identity that
spots_ui::sync_handles warns about is untouched.

app.slint: 2834 -> 2490 lines. Verified by running the desktop app on a
photograph, with the crop overlay's guard temporarily forced open so all
three instantiate -- no binding loop, no panic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:35:08 +02:00
dtourolleandClaude Opus 5 ffc9e1aea7 Give the develop view's chrome a file of its own
StatusBar and InfoPanel sat above AppWindow in app.slint, which read as
though they were part of the application shell. They are not: neither is
instantiated anywhere but the develop view, and the shell's actual job --
choosing which of the five screens is up -- is easier to follow without
two unrelated components standing in front of it.

Moved verbatim to develop.slint, matching src/develop.rs. No behaviour
change; app.slint loses 232 lines and gains one import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:33:17 +02:00
dtourolleandClaude Opus 5 cafa63ca6f Let the develop column ask how wide it needs to be
Build and test / Desktop (Linux) (push) Successful in 21m53s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 32s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 53m45s
The column was 280px, a number chosen for a tablet, with 380px bolted on
later for a desktop. Both were guesses at how much room the widest row
inside needs, and a guess is what cannot work here: the mode strip is one
chip per attribute the *operation set declares*, so the row is generated
and no constant in app.slint can track it.

When the guess came up short the failure was not a tidy clip. The
Flickable inside the column never had its `viewport-width` set, so the
viewport took its content's preferred width, and a viewport wider than
its Flickable is *centred* in it — the same rule the note on the seam's
`x: 0` already records a few lines below. So the column lost half of each
edge rather than one of them: "HISTOGRAM" read "ISTOGRAM", "Straighten"
read "aighten", Copy sat centred while Paste ran off the far side. It
looked like a rendering fault and it was an alignment one.

So the column asks instead of guessing. Every panel that can appear in it
— image, histogram, geometry, settings transfer, masks, repairs, adjust,
history — now publishes a `content-width`: how wide it has to be before
it starts clipping itself, read off its own layout rather than asserted.
Each declares that as its `min-width` too, and that is what makes the
aggregation automatic: `column` is a layout, so it already reports the
largest minimum among its children, and it does so for the panels that
come and go with the mode as well, which live inside `if`s and cannot be
named from outside. Grep `content-width` in ui/dr-ui/ui to see every
panel with a say in the answer. The mode strip is named explicitly only
because it is pinned outside that layout, so nothing else measures it.

There is no floor left. A floor is one more guess and the panels state
their own minimums now. The only thing still above the measurement is
`panel-max-width`, which is not a size but a policy — a column may not
take the window from the photograph it exists to serve — and it comes
from Rust beside `layout-class` because a width read from `root.width`
inside the layout that `root.width` depends on is a binding loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:23:44 +02:00
dtourolleandClaude Opus 5 d6380fecc8 Make the People screen a place work can be done
Five faults, all on one screen, and the Slint and Rust halves of each have to
land together.

**Regroup froze the window.** It ran inside the Slint callback, on the UI
thread. It is much faster now, but fast is not bounded — the work grows with the
library, and the one thing that must not grow with the library is how long the
window stops answering. It runs on a worker thread with an mpsc channel and a
250ms poll, like every other long pass in this module, and the button says what
it is doing instead of the window going quiet. Cancellation is dropping the
receiver. Reclustering also prunes the empty groups the previous pass left, so
pressing the button twice no longer fills the rail with "Unnamed (0 faces)".

**The faces were a single row running off the screen.** The comment on the
layout claimed to be a wrapping row; Slint has no flow layout and a
HorizontalLayout does not wrap, so a person with forty faces was a person whose
faces could not be reviewed past the fifth. It is now laid out the way the
library grid lays out thumbnails, with the same arithmetic: choose how many
columns of roughly the requested size fit, then divide the width between them so
the cells fill the row exactly and nothing overhangs.

**The header did not fit a phone.** A 240px name field beside five buttons is
wider than an Android screen — and worse than not fitting, a layout cannot be
narrower than its children's minimums, so the row reported that oversized
minimum upwards and inflated the whole screen. The faces grid is its sibling, so
it would have been measured against a width that was never on the display. The
header is now two rows, the actions sit in a Flickable that scrolls rather than
overflowing, and the rail narrows to 132px on the compact class.

**Strangers crowded out the people who matter.** Most clusters in a real library
are passers-by and other people's guests. "Not interested" sets a group aside;
the rail hides it and says how many are hidden, with one button to bring them
back. Reversible, and never a deletion — see the catalog commit for why.

**A face was a dead end.** Identifying someone and then having no way to see
their photographs is a filing cabinet with no drawer handles. "Show photos"
narrows the library grid to that person and leaves a chip on the filter bar
saying so, which is also how it is cleared. It is a term on `RatingFilter`
rather than a grid scope of its own, exactly as that struct's own doc says new
narrowing terms should be — so the count and the cells are narrowed by the same
thing, and it composes with the others for free. Suggested faces count, not only
confirmed ones, or a freshly grouped person would show an empty grid.

Crops are read from where they are now stored, falling back to cutting one out
of the proxy for faces indexed before that existed.

480 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:21:31 +02:00
dtourolleandClaude Opus 5 79c0520506 Keep the face, not just a way to find it again
A face was drawn by decoding the 1024px proxy it was found on and cutting the
box out again, every time the People screen opened. That made the screen a
derivative of the thumbnail cache: evict a proxy — which the cache may do at any
moment — and the cell goes blank, with no way back short of re-fetching the
original over the network and re-detecting it. It also cost a full JPEG decode
per image, per visit, to show a 96px cell.

So the crop is cut once, when the pixels are already in hand at detection time,
and kept. A 160px JPEG is a few KB against the ~250 KB proxy it replaces reading.

Where it lives is the interesting part. The catalog snapshot is uploaded *whole*
on every sync and downloaded by every device, so a crop column there would put
tens of MB on every round trip — the exact cost `face_shard`'s 25 MB cap exists
to bound, and the reason bulk per-face data lives in shards already. Crops
therefore travel in the face shards, beside the embeddings, and
`snapshot_for_upload` strips them from the copy it writes. Nothing reads a crop
out of a merged remote catalog — the merge touches collections and keywords only
— so a receiving device loses nothing. A shard carrying crops holds around 3,500
faces rather than 22,000, which is the price of a second device showing People
immediately instead of re-fetching every proxy.

The column is nullable and the reader falls back to the proxy, so a face indexed
before this still works and the next indexing pass fills it in.

V10 also adds `people.ignored`, for a person the user has looked at and does not
want to identify. Most clusters in a real library are strangers — passers-by,
other people's guests, a face on a poster — and there is no way to tell "not yet
looked at" from "looked at, don't care" without recording the second. It is a
column rather than a deletion because a deleted cluster comes straight back on
the next Regroup: the faces are still there and still similar, and nothing short
of remembering the judgement survives re-clustering. Same argument
`face_person_rejected` makes one level down.

And `prune_empty_unnamed`, for what clustering leaves behind. Regroup creates a
person per unanchored group and never removed the previous run's now-empty ones,
so pressing it twice added a rail entry per group it no longer believed in.
Named people are never touched however empty — a name is user data — nor is a
merge tombstone, which must outlive its faces to keep redirecting.

298 tests pass, including that the snapshot carries no crops while the live
catalog keeps them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:17:44 +02:00
dtourolleandClaude Opus 5 7275c020d7 Group people at the threshold the library actually supports
0.90 left a third of the reference library ungrouped: 1,213 of 1,813 faces in a
group, and the rest sitting alone in a screen that had nothing to offer for
them.

"Is 0.90 too tight" is not answerable from the number. It is a probability, and
which cosine it lands on depends on the calibration — so the first half of this
is a way to ask the question properly. `face_index --tune` runs the real
clusterer over the real embeddings at ten thresholds and prints what each one
produces. It writes nothing; comparing thresholds by applying them would have
each one pollute the next.

On the reference library:

      P   cosine   groups  grouped  largest
   0.95    0.449      311      62%       51
   0.90    0.403      316      67%       51
   0.85    0.374      318      70%       57
   0.80    0.353      328      74%       69
   0.75    0.335      327      77%       69
   0.70    0.319      326      79%       81
   0.50    0.267      303      85%       90

The count of *groups* is the signal, not the count of grouped faces. Loosening
from 0.95 makes it climb: real people are being assembled out of fragments. It
peaks at 0.80 and then falls — and a falling group count while the grouped faces
keep rising is the shape of over-merging, separate identities being welded
together. That is the FR-CULL-10 failure, and the one the user cannot undo by
hand.

So 0.80: the loosest setting still building people rather than melting them
together. A third more of the library gets grouped than at 0.90, and the largest
group grows by eighteen faces rather than by forty.

The table is one library, and the doc comment says so — `--tune` reruns it on
any other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:17:16 +02:00
dtourolleandClaude Opus 5 c10dca984f Regroup the library without stopping the window
Pressing Regroup on a real library did not come back. Clustering 1,813 faces is
the textbook agglomeration — compute every pairwise cosine, then repeatedly scan
all live group pairs, score each with average link, and merge the best — and the
scan is inside the loop. Each merge rescans every surviving pair, and each score
is recomputed from scratch over every cross pair. Some 1.6 million pair scores
per merge, some 700 merges to do.

Three changes, none of which alter the answer.

Only above-threshold pairs can ever matter. An average that reaches the
threshold must have at least one term at or above it, so two groups with no
qualifying pair between them can never merge — not now, and not after any
sequence of merges, since merging only adds terms. The new `neighbours` module
produces exactly that sparse list: 7,875 pairs rather than 1.6 million on the
reference library. It also means the n^2 matrix is never materialised, so memory
goes from O(n^2) to O(edges) — 2.5 GB to a few hundred KB at 25,000 faces.

Merges cannot cross components, so the connected components of that graph are
independent problems: four hundred small agglomerations instead of one large one.

Average link is additive — sum(A u B, C) = sum(A, C) + sum(B, C) — so a merged
group's scores follow by addition. Kept as running (sum, count) per adjacent
pair, a score costs one division instead of a nested loop, and a heap with lazy
invalidation replaces the rescan.

Measured on the reference library: 0.28s, release, for all 1,813 faces.

An exact ANN index was tried and removed, and neighbours.rs records why so it is
not rediscovered as a good idea. IVF with a triangle-inequality bound is exact
and prunes beautifully on synthetic clusters; on real embeddings it prunes
*nothing* — 946 of 946 cell pairs survive. Median pair angle is 88.5 degrees and
the merge threshold is 66.2, so the bound needs cells of radius under ~10
degrees, but two photographs of the same person sit 36-60 degrees apart. No
ball-based partition of a 512-d near-orthogonal space can be tight enough. So
the scan stayed exhaustive and got an unrolled dot product and its blocks spread
across cores instead.

Correctness is held by keeping the old implementation as an oracle: three tests
run both engines over the same population — plain, under co-occurrence and
anchor constraints, and with a size-weighted calibration — and assert the
clusters are identical. Determinism is asserted at a size where the threaded
path is in play.

62 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:16:55 +02:00
dtourolleandClaude Opus 5 7dfbe3184a Merge master into the face branch
Build and test / Desktop (Linux) (push) Successful in 21m37s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Successful in 25s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 44m27s
Master moved 24 commits while this branch was building the face sweep, and one
of them changed how the interface reaches a server: `remote.rs` is now the only
file that names a connector, and everything else takes `&dyn RemoteBackend`.
The face sweep was written before that landed and built a `NextcloudBackend`
directly. Git merged the text without complaint and the result did not compile,
which is the useful kind of conflict — it goes through `remote::connect` now,
like every other pass.

Two documentation conflicts, both resolved toward master. `code-health.md` was
an add/add: master's copy carries the CH resolution for the backend seam and a
better provenance note, so it wins outright, with its measured figures re-taken
against the merged tree rather than either side's — `run()` is 1,855 lines now,
2,042 tests, 793 traceability tags. `traceability.md` is generated, so it was
regenerated rather than hand-merged.

The seam grades in code-health.md are unchanged by this merge. That is worth
noticing rather than glossing: the face work went into the seams that already
existed — `run()`, `library.rs`, `AppWindow` — which is exactly the pressure
CH-1 describes rather than evidence against it.

All four CI jobs pass: desktop (fmt, clippy, test, build), layering, traceability,
and the Android cross-build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:27:45 +02:00
dtourolle 28c046130c Merge branch 'master' into android-bundled-face-models 2026-08-27 20:22:42 +02:00
dtourolleandClaude Opus 5 7b4263ceb9 Bring master's display and parity work under the new checks
Build and test / Desktop (Linux) (push) Successful in 21m3s
Build and test / Layer separation (push) Successful in 38s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 34s
Build and test / Android (aarch64) (push) Failing after 33m40s
Master moved sixteen commits while these fixes were being written — per-display
colour, the frame-budget measurement that decides FR-DSP-2, and a declared
node running without being compiled. Merged here rather than on master so the
conflicts are resolved where they can be tested.

Two files overlapped and neither was interesting. `lib.rs` gained `mod remote`
from this branch and `mod display_ui` from master, which git resolved on its
own. `docs/traceability.md` is generated, so it was regenerated from the merged
tree rather than hand-resolved — hand-editing a generated matrix produces one
that agrees with neither side. Coverage reads 59.9% (106/177), up from 55.4%,
entirely from master's tagging.

The check worth having run is `the_interface_names_no_operation` against
master's new `display_ui.rs` and its 195 changed lines of `develop.rs`: a new
UI module written without knowledge of this gate passes it. That is the
evidence the gate is not merely satisfiable by the code that shipped with it.

fmt clean, clippy clean at -D warnings, 2087 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:10:56 +02:00
dtourolleandClaude Opus 5 371038614f Fetch the repairs first, or they never happen
The repair added in 051447b was appended to the work list:

    wanted.extend(repair);

Behind every un-indexed image in the library. On this library that is position
23,000-odd — roughly two hours of fetching before the first repair is reached,
which inside one session is indistinguishable from the feature not existing.
The log said it had found them and the screen stayed empty, which is the worst
combination of the two.

They go first now. There are a few hundred of them against tens of thousands of
un-indexed images, and they are precisely the images the People screen is
failing to draw at this moment — so the ordering costs nothing and is the
difference between the grid filling in within a minute and not filling in at
all.

A failure to build the un-indexed half no longer discards the repairs either:
the pass runs with whatever it has rather than returning empty.

Verified on the live catalog: 455 images hold faces, 251 already have a proxy
from the fixed sweep, and the remaining 204 are what now sits at the front of
the queue. The stored proxies check out — a valid JPEG at the large class,
49 KB — so the storing half of 051447b was already working.

471 tests pass, including one that the orphan is ordered ahead of the library.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:58:32 +02:00
dtourolleandClaude Opus 5 242374fd0f Let the interface hold a backend without knowing whose it is
`dr-sync` defines `RemoteBackend` and a capability model the engine adapts
to, so a second backend can be added without touching the code that uses
one. That boundary was documentation. Seven files in `dr-ui` constructed a
`NextcloudBackend` directly, ten functions took one by concrete type, and
exactly two call sites in the tree — both inside `dr-sync` itself — ever held
the trait object. A WebDAV or local-folder backend would have had a
well-written trait to implement and nowhere to go afterwards.

The change is smaller than the finding suggests, because the trait was
already right. Every method the UI has ever called on a backend — `get`,
`put`, `list`, `delete`, `create_dir`, `move_to` — was already on it, so
nothing had to be added and no behaviour moved. Ten signatures widened to
`&dyn RemoteBackend`, sixteen constructions became `remote::connect`, and
`remote.rs` is now the only file in the interface that names a connector.

`connect` returns `Result<Box<dyn RemoteBackend>, RemoteError>`. The error
type is `dr-sync`'s rather than the connector's, which is why every call site
kept its shape — the `match`, the `let Ok(..) else`, and
`.map_err(ScanFailure::local)?` all still read as they did.

One wrinkle worth recording: `&Box<dyn Trait>` does not reach `&dyn Trait` on
its own. The compiler reaches for unsizing, which wants
`Box<dyn RemoteBackend>: RemoteBackend`, and reports a confusing missing impl
rather than suggesting a deref. Twelve call sites therefore say `&*backend`,
and two say `let backend: &dyn RemoteBackend = &*backend` where a borrow is
shared across lanes.

What this does *not* do is abstract credentials. `AppCredentials` is an app
password from Login Flow v2 — a Nextcloud protocol, not a general notion of
authenticating to a remote — and seven files still name it. An OAuth token, a
bucket key pair and an app password have no useful common shape, so deciding
what an account is across backends before a second one exists would be a
confident guess. code-health.md CH-2 now records that as the remaining half,
and it should wait for the backend that forces it.

Verified: fmt clean, clippy clean at -D warnings, 2041 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:51:23 +02:00
dtourolleandClaude Opus 5 051447bda6 Keep the proxy a found face will be cropped from
Every cell on the People screen read "no preview" while the sweep was happily
reporting 893 faces found. Both were true. Faces were being detected and stored
correctly; there was simply nothing left to draw them from.

A face is stored normalised and drawn by cropping the proxy it was found on —
`identity::decode_proxy` reads `FACE_TIER` out of the thumbnail store. The
fetching sweep fetched a preview, detected on it, wrote the faces and dropped
the pixels. So every face it found pointed at a proxy that had never been
stored, and the grid had nothing to cut.

Worse, that state could not repair itself: the image has its `face_index` row,
so it is not outstanding work and no later pass would look at it again.

Two fixes, and the first is nearly free. The sweep now keeps the proxy — it has
already paid the round trip and the decode, and the crop needs those same pixels
the moment the user opens the person. Kept **only where a face was found**:
two thirds of a personal library is landscapes and documents (docs/faces.md
§7a), those will never be cropped, and skipping them keeps this well clear of
the whole-library cost `SWEEP_THUMB_SIZE` deliberately avoids. The downscale to
the large class happens after detection, which is the last use of the full
buffer.

Second, the sweep now picks up images that have faces with no proxy, whatever
put them in that state — this bug, or an ordinary cache eviction, which would
have produced exactly the same empty grid. Re-running detection repairs it and
loses nothing: `record_detections` replaces rather than appends and carries the
user's confirmations across the replacement. That makes the screen
self-healing rather than dependent on nobody ever evicting a thumbnail.

The proxy is stored *before* the detections. A kill between the two then leaves
a proxy with no faces — which the next pass simply re-indexes — rather than
faces with no proxy, which is the state that cannot recover.

Note for the library already part way through a sweep: the 986 images indexed
before this will be picked up by the repair route on the next run.

470 tests pass, including one that a face whose proxy is gone becomes work again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:35:12 +02:00
dtourolleandClaude Opus 5 617262b4da Give a contributor a way in
There was none. 14 documents, 177 numbered requirements, and all of it
written for someone who has already decided to work on this — nothing that
tells a newcomer which door is unlocked, what a first build needs, or why it
takes so long.

CONTRIBUTING.md points the first door at the operation format, because
"add a develop node" is a genuinely one-file contribution and the best first
experience this codebase can offer: no Rust, no shader edit, no UI change,
and its tests declared in the same file. It teaches the architecture's
central idea on the way through, which is why the invariant test added in
the previous commit is named there rather than left to be discovered.

Three things that were folklore are now written down: Git LFS is a
prerequisite, the first build resolves 826 crates and is not hanging, and
Slint needs pkg-config, libfontconfig1-dev and libxkbcommon-dev. The LFS one
proved itself while writing this — a fresh worktree hit exactly the failure
`dr-segment`'s build script is written to catch, which is the argument for
saying so before it happens rather than after.

rust-toolchain.toml pins 1.92.0 because `build-and-test.yml` already does
and says why: a floating toolchain turns an unrelated push into a mystery
failure. The two checks that gate every push are the two most sensitive to
compiler version — rustfmt's output changes between releases, so a
contributor on a newer stable can produce a diff nobody wrote on a line
nobody touched, and `clippy -D warnings` is the same story with new lints.
`rust-version = "1.92"` in the manifest stays where it is; it is a minimum,
and this is the upper bound it cannot express.

Also states the convention the tooling cannot enforce, from code-health.md
CH-4: close a requirement with a test that would fail if the behaviour were
removed. Coverage that moves slowly and means something beats coverage that
moves quickly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:33:37 +02:00
dtourolleandClaude Opus 5 5e4c7424ed Fail the build when the interface names an operation
FR-DEV-3a is the property the declarative pipeline rests on: a node in
`ops/` is one file because nothing in `ui/` has to learn about it. It was
true — all fifteen ids grepped across `ui/` yield one hit, a localisation
test — and it was held by discipline alone.

That is the wrong mechanism for it. The failure is silent and cumulative:
special-casing one operation to fix a layout problem is defensible on its
own, and by the fifth the panel names half the chain and "a new operation is
one file" has stopped being true without any single commit having broken it.
Nothing would have told us.

The ids are read from `ops/*.yaml` rather than listed, so a node added
tomorrow is covered without anyone remembering this file — the same reason
`traceability` parses its denominators from `requirements.md` at run time.

Two decisions worth recording, because both are the difference between a
test that holds and one that gets deleted:

**Only string literals count.** `texture`, `contrast` and `clarity` are also
ordinary graphics and English terms, and `texture` appears throughout `dr-ui`
meaning a GPU texture. Matching bare words would fail constantly for reasons
unrelated to the invariant.

**`#[cfg(test)]` items are exempt, and finding them needs more care than it
looks.** The first version cut each file at the first textual match of
`#[cfg(test)]`, which in `develop.rs` is a *doc comment discussing the
attribute* at line 969 — it read 18% of the most important file in the scan
and passed. It now matches the attribute only as a whole line and skips the
item by brace depth, and `MIN_SHIPPING_FRACTION` fails the test outright if
the scan ever swallows the file again. The dangerous failure here is not a
false alarm, which someone investigates; it is examining nothing and
reporting success.

Verified both ways: it passes on the tree, and an `"exposure"` planted at
develop.rs:3635 — past two `#[cfg(test)]` attributes, exactly where the
first version was blind — fails with the file, the line and the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:33:20 +02:00
dtourolleandClaude Opus 5 2c56729354 Read a descriptor's variants without taking them
Build and test / Desktop (Linux) (push) Successful in 21m4s
Build and test / Layer separation (push) Successful in 27s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 24s
Build and test / Android (aarch64) (push) Failing after 33m42s
Two agents worked in parallel and neither could see this. The frame-budget
instrument matches `ParamKind::Enum { variants }` by value, which was free when
a descriptor was `&'static` and everything in it was borrowed for the life of
the program. Descriptors are owned now — a declaration parsed at run time
cannot hand out a `&'static` — so `variants` is a `Vec` and the arm was moving
out of a shared reference.

Bound by reference instead. The arm only ever reads the length.

The kind of conflict that survives a clean textual merge: git had nothing to
report, and the two changes are only incompatible once they are in the same
tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:25:15 +02:00
dtourolle fb1b44ce47 Merge branch 'worktree-agent-a1e5c8cb565255f5b' into master
# Conflicts:
#	docs/traceability.md
2026-08-27 19:21:10 +02:00
dtourolleandClaude Opus 5 8202c05d9d Format the zoom test the way the gate asks for it
Build and test / Desktop (Linux) (push) Successful in 1h22m24s
Build and test / Layer separation (push) Successful in 2m57s
Traceability / Requirement traces (push) Successful in 1m3s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 33m41s
Whitespace only. `cargo fmt --check` is a required step and the FR-DSP-5 test
arrived disagreeing with it — kept as its own commit so it can be skipped
wholesale rather than read for a change that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:20:23 +02:00
dtourolleandClaude Opus 5 29da637fa2 Record what a contribution costs, and what would lower it
An audit of code quality and extensibility, written because the answer to
"how hard is it to add a feature here?" splits cleanly in two and the split
is not where it looks.

The develop pipeline is genuinely open: a new operation is one file in
`ops/`, and the claim that no code in `ui/` names an operation turns out to
be true — all fifteen ids grepped across every .rs and .slint file in `ui/`
yield one hit, a localisation test. `dr-ui` is the opposite: every feature
lands in `run()`, one of the `wire()` functions, and a root component
carrying 472 members, so contributors collide by construction.

Five findings, CH-1 to CH-5, each with a falsifiable "done when" in the
idiom technical-debt.md already uses. The distinction from that document is
deliberate and stated: it records compromises that were chosen and are
load-bearing until their criterion is met; this records friction nobody
chose. A section listing what must NOT be tidied comes before the findings
for the same reason.

CH-1 is not a new diagnosis. view-composition.md specified the fix on
2026-08-09, when `run()` was 500 lines; it is 1,810 now, the two view
booleans it described are five, and the conditional chains it predicted in
app.slint are five-term conjunctions. The entry references that spec rather
than restating it, and quantifies the cost of the delay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 19:18:33 +02:00
dtourolleandClaude Opus 5 0cd3ef1b3f Run a node declaration without compiling it
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
declarative nodes landed — it was simply resolved at build time. Nothing about
a declaration requires the compiler: everything it produces is data plus a
WGSL string, and the composer already assembles WGSL at run time from whatever
operations are active. So this is not a new mechanism. It is the existing one,
loaded later (FR-PLG-2).

`DeclaredOp` implements `Operation` from an owned `Declaration` — one
interpreter over many declarations, where `build.rs` emits generated code per
node. The generated path stays, as FR-PLG-2 says it should: a generated
`match` is faster than an interpreted one, the built-ins' declared `tests:`
have to run under `cargo test`, and generated source is inspectable in a way
an interpreter's state is not.

**The reader is now one file, read by both.** `src/declared/decl.rs` and
`src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being
modules of the crate, and they produce a neutral `Declaration` that names no
Rust type. The build script's job is reduced to *rendering* that declaration
as Rust; `DeclaredOp` converts the same declaration into descriptors and
`Expr::eval` walks the same tree the renderer writes out. There is one
grammar, one set of validations and one set of error messages, so "a plugin is
the same kind of thing as a built-in" is structural rather than aspirational.

What remains genuinely written twice is the pair of backends — an arithmetic
node rendered as Rust here and evaluated there — and that is what the parity
test stands between. `tests/declared_parity.rs` parses every built-in
declaration at run time and asserts the composed WGSL is byte-for-byte what
the generated implementation produces, with the uniform block bit-for-bit
identical, at both ends of every parameter's range and at four interior
points; then again over the whole develop chain with the declared nodes
swapped in, which is what covers uniform slot ordering and helper
de-duplication between operations. A third test asserts the declared and
hand-written nodes partition `ops/` between them, so coverage cannot shrink
silently.

Bit-for-bit rather than within a tolerance, because a tolerance is where a
real divergence hides. The one thing that had to be got right for that to hold
is number literals: `expr::as_f32` rounds a decimal exactly once, through the
same shortest-round-trip text the compiler is handed, rather than rounding an
`f64` a second time.

Not in scope, and deliberately untagged: load-time WGSL validation
(FR-PLG-11), id namespacing, a plugin directory read at startup, and pass
nodes (FR-PLG-2a). Those are separate work, and tagging them from here would
be the overstatement the spec's own §7 warns about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:08:34 +02:00
dtourolle 3b5bba62f1 Merge branch 'worktree-agent-aa9f4356c13893373' into master
# Conflicts:
#	docs/traceability.md
2026-08-27 19:08:32 +02:00
dtourolle 510d1a26cb Regenerate the traceability matrix
The platform layer was never scanned, so every FR-PLAT-* and NFR-PORT-* tag in
dr-plat was invisible. Coverage 51.4% -> 57.6%, almost all of it pre-existing
tags that were simply not being counted.
2026-08-27 19:08:23 +02:00