Stand each person in the grouping pass by at most 100 references
Benchmarks / CPU and I/O (per commit) (push) Failing after 6m23s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 55s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 54s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 2m21s
Build and test / Windows (x86_64, cross) (push) Failing after 3m5s

Every face the user has ruled on entered the pass as an anchor, and the
scan is exhaustive by design (`dr_face::neighbours`), so a person with
750 confirmed faces cost 750 comparisons against every other face in
the library — and the cost of a library grew with how well it was
named. Most of those comparisons said nothing new: thirty frames from
one afternoon are one point of view, not thirty, and a face that
matches one of them matches the rest.

Each person now enters through at most 100 of their anchored faces
(`dr_face::references`). Eligible are those whose raw embedding is at
least 15 long — one above the gallery floor, since a reference speaks
for someone rather than merely being admitted — with an unmeasured
length admitted as it is everywhere else. From those, the set spanning
the greatest volume is chosen greedily: the longest vector first, then
at each step the face with the largest component orthogonal to the
chosen so far. That is pivoted Gram–Schmidt, and the product of the
residuals it picks is the Gram determinant, so the greedy step is the
exact greedy on the objective. A near-duplicate of a chosen face has
no residual and is passed over; the one profile shot among two hundred
frontal frames is taken early; faces inside the span of the chosen add
no volume and are not taken to fill the cap.

The faces not chosen keep their confirmations and are not touched by
the pass — they stay in the anchor map, so it never releases them —
they are simply not compared. A person none of whose faces is long
enough is still stood for, by their longest, rather than losing their
anchor and having their next face filed as a stranger. Under the cap
nothing changes: every eligible face stands, and the short ones stay
in as the probes they were.

At the reference library's 3,851 confirmations the scan shrinks by
about a fifth; at 15,000 it is a fifth of what it was.
This commit is contained in:
2026-09-20 13:29:16 +02:00
parent 301e6f3828
commit 764ad55ead
5 changed files with 360 additions and 5 deletions
+112
View File
@@ -1069,6 +1069,79 @@ impl Population {
ids.push(f.face);
}
// Not every anchored face enters the scan: each person is stood for
// by their references (`dr_face::references`), and the rest of their
// faces stay out of the comparison. They keep their place -- they are
// in `anchors`, so the pass never releases them -- and they are the
// cost that would otherwise grow with every name the user gives.
let mut by_person: std::collections::BTreeMap<u64, Vec<usize>> =
std::collections::BTreeMap::new();
for (i, c) in candidates.iter().enumerate() {
if let Some(p) = c.confirmed_person {
by_person.entry(p).or_default().push(i);
}
}
let mut keep = vec![true; candidates.len()];
let mut anchored = 0usize;
for members in by_person.values() {
anchored += members.len();
// Only a person over the cap loses anyone: under it, `select`
// returns every eligible face, and the ineligible are kept too,
// since a short vector was a probe before and still is.
if members.len() <= dr_face::references::MAX_REFERENCES {
continue;
}
let embeddings: Vec<&[f32]> = members
.iter()
.map(|&i| candidates[i].embedding.as_slice())
.collect();
let quality: Vec<Option<f32>> =
members.iter().map(|&i| candidates[i].quality).collect();
let chosen = dr_face::references::select(
&embeddings,
&quality,
dr_face::references::MAX_REFERENCES,
);
for &i in members {
keep[i] = false;
}
for &k in &chosen {
keep[members[k]] = true;
}
// A person none of whose faces is long enough to vouch is still
// a person, and a pass they had no anchor in would file their
// next face as a stranger. The longest stand in.
if chosen.is_empty() {
let mut by_quality = members.clone();
by_quality.sort_by(|&a, &b| {
let qa = candidates[a].quality.unwrap_or(0.0);
let qb = candidates[b].quality.unwrap_or(0.0);
qb.total_cmp(&qa).then(a.cmp(&b))
});
for &i in by_quality.iter().take(dr_face::references::MAX_REFERENCES) {
keep[i] = true;
}
}
}
let left_out = keep.iter().filter(|k| !**k).count();
if left_out > 0 {
let mut i = 0;
candidates.retain(|_| {
i += 1;
keep[i - 1]
});
let mut i = 0;
ids.retain(|_| {
i += 1;
keep[i - 1]
});
log::info!(
"references: {} of {anchored} anchored face(s) stand for {} people; {left_out} left out of the scan",
anchored - left_out,
by_person.len(),
);
}
Ok(Self {
cal,
candidates,
@@ -2121,6 +2194,45 @@ mod tests {
assert_eq!(after[0].suggested_faces, 3);
}
/// A person over the reference cap is stood for by a chosen few, and the
/// rest of their faces stay where they are: not compared, not released,
/// and still theirs when the next face arrives.
#[test]
fn a_well_covered_person_is_stood_for_by_their_references() {
let many = dr_face::references::MAX_REFERENCES + 50;
let catalog = catalog_with(many + 1);
for i in 0..many {
put_face(&catalog, i as u64 + 1, 0, 1.0 - (i as f32) * 1e-3);
}
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let him = faces::people(catalog.connection()).unwrap()[0].id;
faces::rename_person(catalog.connection(), him, "Ian").unwrap();
// Every one of his faces lies in one plane, so two references span
// them all and the selector stops there rather than filling the cap.
let pop = Population::read(&catalog, TEST_MODEL).unwrap();
let standing = pop
.candidates
.iter()
.filter(|c| c.confirmed_person.is_some())
.count();
assert!(
(2..=dr_face::references::MAX_REFERENCES).contains(&standing),
"{standing} of {many} faces entered the scan"
);
assert_eq!(pop.anchors.len(), many, "the rest lost their anchor");
put_face(&catalog, many as u64 + 1, 0, 0.98);
recluster(&catalog, TEST_MODEL, &FaceSettings::default()).unwrap();
let after = faces::people(catalog.connection()).unwrap();
assert_eq!(after.len(), 1, "a second Ian appeared: {after:?}");
assert_eq!(
after[0].suggested_faces,
(many + 1) as u64,
"faces were released"
);
}
/// `dr-types` sits below the face engine and cannot name its constant, so
/// it restates the number. This is the thing that stops the two drifting:
/// a settings page marking 0.80 as "default" while the engine had moved to