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>
This commit is contained in:
2026-08-29 22:32:26 +02:00
co-authored by Claude Opus 5
parent a4b9deaf96
commit 23c74d7063
10 changed files with 884 additions and 88 deletions
+48
View File
@@ -117,6 +117,29 @@ impl FaceCell {
}
}
/// Put a grouping preview into words.
///
/// **The group count leads, not the grouped-face count.** They move in opposite
/// directions on either side of the right setting, and only one of them says
/// which side you are on: loosening gathers fragments into people, so the group
/// count climbs — until it starts welding separate people together, at which
/// point it *falls* while the grouped faces keep rising. `dr_face::cluster`
/// records that measurement at length. A line that led with "1,340 of 1,813
/// faces grouped" would make the over-merged setting look like the best one.
///
/// The largest group is here for the same reason: it is where over-merging
/// shows up first and most legibly, because a user who knows their own library
/// knows whether anyone in it has been photographed six hundred times.
pub fn preview_label(p: &crate::faces::GroupingPreview) -> String {
if p.faces == 0 {
return "No faces indexed yet, so there is nothing to group.".into();
}
format!(
"{} group(s), holding {} of {} faces. Largest: {}.",
p.groups, p.grouped, p.faces, p.largest
)
}
/// Everything the Identity screen draws.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct IdentityView {
@@ -646,8 +669,33 @@ pub fn delete_all(catalog: &Catalog) -> Result<u64, dr_catalog::CatalogError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::faces::GroupingPreview;
use dr_catalog::faces::DetectedFace;
#[test]
fn a_preview_leads_with_the_group_count() {
let line = preview_label(&GroupingPreview {
faces: 1813,
groups: 328,
grouped: 1341,
largest: 69,
});
// The group count is the number that says which side of the right
// setting you are on, so it is the number the sentence starts with.
assert!(line.starts_with("328 group"), "{line}");
assert!(line.contains("1341 of 1813"), "{line}");
assert!(line.contains("69"), "{line}");
}
/// The ordinary state of a library nobody has run the indexer over — and
/// "0 group(s), holding 0 of 0 faces" would read as a failure of the dials
/// rather than as an absence of input.
#[test]
fn a_preview_of_nothing_says_there_is_nothing() {
let line = preview_label(&GroupingPreview::default());
assert!(line.contains("No faces indexed"), "{line}");
}
fn catalog() -> Catalog {
let dir = std::env::temp_dir().join(format!(
"dr-identity-{}-{:?}",