Files
picpeak/backend/__tests__/integration/faceMergeSuggestions.test.js
T
Paul Nothaft 3583c924da feat(faces): consolidate look-alike clusters after a scan, and suggest the rest (#1107)
consolidate() has existed since #1074 and described this exact symptom in its own
comment, but its only caller was recluster() — i.e. when an admin pressed
Re-group people. After a normal background scan the centroids converged and
nobody looked, so a gallery settled with 14 people that should have been 8.

It now runs when a scan drains. There is no scan-finished event to hook, so an
idle worker asks whether the events it touched have actually drained — 'a worker
went idle' is deliberately not treated as sufficient, because with concurrency
above one the others may still be working.

The uncertain band asks instead of acting: pairs between the assignment
threshold and the stricter auto-merge one surface as accept/dismiss suggestions,
with sticky dismissals. Nothing merges silently — a pass that merged anything
reports it and points at Split.

Review rounds hardened it against overruling explicit decisions: it no longer
absorbs ignored clusters (mergePeople ORs is_ignored onto the survivor, which
would have hidden a real person), no longer merges dismissed pairs, no longer
undoes a manual Split (which now records a separation), and no longer runs after
detection is switched off. The dismissal read fails closed, a failed pass is
retried with backoff rather than lost or hot-looped, and the new table follows
event_people out of exports and backups.

Name autocomplete needs no endpoint — the people list already open is the source,
and it is event-scoped on purpose.

Known limitation, tracked in #1132: separations are keyed on person ids, so a
full re-scan loses them.

Reported by @BraynArts.
2026-08-22 21:39:15 +02:00

453 lines
19 KiB
JavaScript

/**
* Automatic consolidation reporting and the suggestion band (#1107).
*
* Centroids are built to an EXACT cosine similarity rather than jittered
* towards one, because every assertion here is about which side of a threshold
* a pair falls on. `pairAtSimilarity` returns two unit vectors whose dot
* product is the requested number to floating-point precision, and each pair
* is built on its own orthogonal basis so two different pairs are never
* accidentally similar to each other.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-facesuggest-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'facesuggest-test-secret';
const { bootCrmDb } = require('./helpers/crmDb');
let db; let cleanup; let clustering;
// Mirrors the service: merge at match + 0.08, so with a 0.60 floor the
// suggestion band is [0.60, 0.68).
const THRESHOLDS = {
face_match_threshold: 0.6,
face_quality_min_score: 0.7,
face_quality_min_px: 40,
};
const DIM = 64;
/** Two unit vectors whose dot product is exactly `target`, on basis (i, i+1). */
function pairAtSimilarity(target, basis) {
const a = new Float32Array(DIM);
const b = new Float32Array(DIM);
const orth = Math.sqrt(1 - target * target);
a[basis] = 1;
b[basis] = target;
b[basis + 1] = orth;
return [a, b];
}
async function seedEvent(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function insertPerson(eventId, centroid, overrides = {}) {
const [row] = await db('event_people').insert({
event_id: eventId,
centroid: clustering.packEmbedding(centroid),
face_count_total: 5,
model_version: 'test-v1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** One person with one real face, so merge/split have something to move. */
async function insertPersonWithFace(eventId, centroid, overrides = {}) {
const personId = await insertPerson(eventId, centroid, overrides);
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
});
return personId;
}
/** An additional face on an existing person, so a split has something to move. */
async function addFaceTo(eventId, personId, centroid) {
const [p] = await db('photos').insert({
event_id: eventId,
filename: `${Math.random()}.jpg`,
path: '/tmp/x.jpg',
type: 'individual',
}).returning('id');
const photoId = typeof p === 'object' ? p.id : p;
const [f] = await db('photo_faces').insert({
photo_id: photoId,
event_id: eventId,
person_id: personId,
bbox_x: 0, bbox_y: 0, bbox_w: 200, bbox_h: 200,
det_score: 0.99,
embedding: clustering.packEmbedding(centroid),
model_version: 'test-v1',
created_at: new Date().toISOString(),
}).returning('id');
return typeof f === 'object' ? f.id : f;
}
const suggest = (eventId) => clustering.suggestMerges(eventId, { thresholds: THRESHOLDS });
describe('face merge suggestions (#1107)', () => {
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
clustering = require('../../src/services/faceClustering');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the band', () => {
it('suggests a pair between the match and auto-merge thresholds', async () => {
const eventId = await seedEvent('band-inside');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
const out = await suggest(eventId);
expect(out).toHaveLength(1);
expect([out[0].person_a_id, out[0].person_b_id].sort()).toEqual([idA, idB].sort());
expect(out[0].score).toBeCloseTo(0.64, 4);
});
it('stays silent above the auto-merge threshold — consolidate() owns that pair', async () => {
const eventId = await seedEvent('band-above');
const [a, b] = pairAtSimilarity(0.75, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
it('stays silent below the match threshold — further apart than one face would join', async () => {
const eventId = await seedEvent('band-below');
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b);
expect(await suggest(eventId)).toEqual([]);
});
});
describe('what it refuses to ask about', () => {
it('never questions two people the photographer named differently', async () => {
const eventId = await seedEvent('named-apart');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b, { label: 'Beatrix' });
expect(await suggest(eventId)).toEqual([]);
});
it('still asks when only one of the two is named', async () => {
const eventId = await seedEvent('one-named');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a, { label: 'Anna' });
await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
});
it('skips a person marked "not a real person" — that answer was already given', async () => {
const eventId = await seedEvent('ignored');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { is_ignored: true });
expect(await suggest(eventId)).toEqual([]);
});
it('never crosses embedding spaces', async () => {
const eventId = await seedEvent('model-skew');
const [a, b] = pairAtSimilarity(0.64, 0);
await insertPerson(eventId, a);
await insertPerson(eventId, b, { model_version: 'test-v2' });
expect(await suggest(eventId)).toEqual([]);
});
});
describe('dismissal', () => {
it('stops suggesting a pair the photographer rejected, and survives a repeat', async () => {
const eventId = await seedEvent('dismissal');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
expect(await suggest(eventId)).toHaveLength(1);
await clustering.dismissMergeSuggestion(eventId, idB, idA); // reversed on purpose
expect(await suggest(eventId)).toEqual([]);
// A second dismissal hits the UNIQUE constraint. Dismissing twice is a
// double-click, not an error.
await expect(clustering.dismissMergeSuggestion(eventId, idA, idB)).resolves.toEqual({
dismissed: true,
});
expect(await suggest(eventId)).toEqual([]);
});
/**
* The swallow-the-duplicate branch has to discriminate, because the failure
* it must NOT swallow looks identical to the caller: returning
* "kept separate" for a decision that was never written means the pair
* silently comes back after the next scan.
*
* Tested on the predicate directly — provoking a read-only database or a
* dropped table mid-suite would corrupt the shared fixture for every other
* case in this file.
*/
it.each([
['postgres unique violation', { code: '23505', message: 'duplicate key value violates unique constraint' }, true],
['sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT', message: 'UNIQUE constraint failed: event_people_merge_dismissals.event_id' }, true],
['better-sqlite3 unique violation', { code: 'SQLITE_CONSTRAINT_UNIQUE', message: 'UNIQUE constraint failed' }, true],
['sqlite foreign-key violation', { code: 'SQLITE_CONSTRAINT', message: 'FOREIGN KEY constraint failed' }, false],
['sqlite busy', { code: 'SQLITE_BUSY', message: 'database is locked' }, false],
['missing table', { code: 'SQLITE_ERROR', message: 'no such table: event_people_merge_dismissals' }, false],
['postgres read-only transaction', { code: '25006', message: 'cannot execute INSERT in a read-only transaction' }, false],
['no error at all', null, false],
])('%s → swallowed: %s', (_name, err, expected) => {
expect(clustering.isUniqueViolation(err)).toBe(expected);
});
/**
* The dismissal read is the only thing standing between the automatic pass
* and a pair the photographer explicitly separated. If it fails open, a
* timeout silently restores the merge that "Not the same" was supposed to
* prevent — so anything other than a missing table must stop the pass.
*/
it('refuses to consolidate when the dismissal list cannot be read', async () => {
const eventId = await seedEvent('dismissals-unreadable');
// Well above the auto-merge threshold, so only a refusal keeps them apart.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
// Break the read for real rather than mocking knex: dropping a selected
// column makes the query fail with something that is NOT "missing
// table", which is exactly the class that must not fail open.
await db.schema.alterTable('event_people_merge_dismissals', (t) => t.dropColumn('person_b_id'));
try {
await expect(clustering.consolidate(eventId, { thresholds: THRESHOLDS }))
.rejects.toThrow();
// Nothing merged: the pass gave up rather than overriding a decision
// it could not read.
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
} finally {
await db.schema.alterTable('event_people_merge_dismissals', (t) => {
t.integer('person_b_id').notNullable().defaultTo(0);
});
}
});
it.each([
['postgres undefined_table', { code: '42P01', message: 'relation "x" does not exist' }, true],
['sqlite missing table', { code: 'SQLITE_ERROR', message: 'no such table: x' }, true],
// The one that matters: a missing COLUMN is a broken query, not a
// pre-migration install, and must NOT be allowed to fail open.
['postgres undefined_column', { code: '42703', message: 'column "x" does not exist' }, false],
['sqlite missing column', { code: 'SQLITE_ERROR', message: 'no such column: x' }, false],
['statement timeout', { code: '57014', message: 'canceling statement due to statement timeout' }, false],
])('missing-table check — %s → %s', (_name, err, expected) => {
expect(clustering.isMissingTable(err)).toBe(expected);
});
it('normalizes the pair so one row covers both orderings', async () => {
const eventId = await seedEvent('dismissal-normalized');
const [a, b] = pairAtSimilarity(0.64, 0);
const idA = await insertPerson(eventId, a);
const idB = await insertPerson(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idB, idA);
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect(rows[0].person_a_id).toBe(Math.min(idA, idB));
expect(rows[0].person_b_id).toBe(Math.max(idA, idB));
});
});
describe('one suggestion per person per round', () => {
it('does not offer A-B, A-C and B-C for a three-way fragment', async () => {
const eventId = await seedEvent('three-way');
// Three mutually similar centroids, all inside the band.
const base = new Float32Array(DIM); base[0] = 1;
const people = [];
for (let k = 0; k < 3; k++) {
const v = new Float32Array(DIM);
v[0] = 0.9;
v[1 + k] = Math.sqrt(1 - 0.81);
people.push(await insertPerson(eventId, v));
}
await insertPerson(eventId, base);
const out = await suggest(eventId);
// Every returned pair must name people not already spoken for: accepting
// the first suggestion must never leave a second one pointing at a person
// that the merge just deleted.
const seen = new Set();
for (const s of out) {
expect(seen.has(s.person_a_id)).toBe(false);
expect(seen.has(s.person_b_id)).toBe(false);
seen.add(s.person_a_id);
seen.add(s.person_b_id);
}
});
it('offers the most similar pair first', async () => {
const eventId = await seedEvent('ordering');
const [a1, b1] = pairAtSimilarity(0.62, 0);
const [a2, b2] = pairAtSimilarity(0.67, 10);
await insertPerson(eventId, a1);
await insertPerson(eventId, b1);
await insertPerson(eventId, a2);
await insertPerson(eventId, b2);
const out = await suggest(eventId);
expect(out).toHaveLength(2);
expect(out[0].score).toBeGreaterThan(out[1].score);
});
});
describe('manual splits survive the automatic pass', () => {
/**
* The regression that matters most once consolidation runs on every scan:
* a photographer splitting a wrongly-merged cluster produces two people
* who are look-alikes BY CONSTRUCTION, so their centroids sit above the
* merge threshold and the very next scan would put them straight back.
*/
it('records a split as a separation, so consolidation leaves it alone', async () => {
const eventId = await seedEvent('split-protected');
const base = new Float32Array(DIM); base[0] = 1;
// One cluster holding two near-identical faces.
const personId = await insertPersonWithFace(eventId, base);
const extraFaceId = await addFaceTo(eventId, personId, base);
const newPersonId = await clustering.splitPerson(eventId, personId, [extraFaceId]);
expect(newPersonId).toBeTruthy();
const rows = await db('event_people_merge_dismissals').where({ event_id: eventId });
expect(rows).toHaveLength(1);
expect([rows[0].person_a_id, rows[0].person_b_id].sort())
.toEqual([personId, newPersonId].sort());
// Identical centroids — nothing but the recorded separation can stop
// this merge.
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId })).toHaveLength(2);
});
});
describe('consolidation reporting', () => {
it('records what an automatic pass merged, so it is not silent', async () => {
const eventId = await seedEvent('report-merged');
// 0.97 is above the 0.68 auto-merge threshold — consolidate() acts.
const [a, b] = pairAtSimilarity(0.97, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toHaveLength(1);
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(1);
expect(event.faces_last_consolidated_at).toBeTruthy();
});
it('never absorbs an ignored cluster — that would mark a real person ignored', async () => {
const eventId = await seedEvent('consolidate-ignored');
// Well above the auto-merge threshold: only the is_ignored flag can
// stop this pair.
const [a, b] = pairAtSimilarity(0.97, 0);
const real = await insertPersonWithFace(eventId, a);
const junk = await insertPersonWithFace(eventId, b, { is_ignored: true });
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
// Both still standing, and the real person is still guest-visible —
// mergePeople ORs is_ignored onto the survivor, so absorbing the junk
// cluster would have hidden a real person from the gallery.
const survivors = await db('event_people').where({ event_id: eventId }).select('id', 'is_ignored');
expect(survivors.map((p) => p.id).sort()).toEqual([real, junk].sort());
const realRow = survivors.find((p) => p.id === real);
expect(realRow.is_ignored === true || realRow.is_ignored === 1).toBe(false);
});
it('never merges a pair the photographer said was not the same person', async () => {
const eventId = await seedEvent('consolidate-dismissed');
// Also above the auto-merge threshold: the dismissal is the only thing
// standing between these two, which is the point — a human "no" has to
// outrank the automatic pass, not just the suggestion list.
const [a, b] = pairAtSimilarity(0.97, 0);
const idA = await insertPersonWithFace(eventId, a);
const idB = await insertPersonWithFace(eventId, b);
await clustering.dismissMergeSuggestion(eventId, idA, idB);
const merged = await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
expect(merged).toEqual([]);
expect(await db('event_people').where({ event_id: eventId }).count({ c: '*' }).first())
.toEqual(expect.objectContaining({ c: 2 }));
});
// NOT covered by a test: reporting what a pass merged before it died
// partway. `consolidate` calls `mergePeople` through the module-local
// binding, so a spy on the export cannot intercept it, and no realistic
// database failure lands on the second merge only. The recording therefore
// sits in a `finally` — each mergePeople is its own transaction, so a pass
// that throws has still committed what it did, and the alternative is a
// real merge going unreported. Verified by reading, not by assertion.
it('clears a previous count when a later pass merges nothing', async () => {
const eventId = await seedEvent('report-cleared');
await db('events').where({ id: eventId }).update({ faces_last_consolidated_count: 7 });
const [a, b] = pairAtSimilarity(0.5, 0);
await insertPersonWithFace(eventId, a);
await insertPersonWithFace(eventId, b);
await clustering.consolidate(eventId, { thresholds: THRESHOLDS });
const event = await db('events').where({ id: eventId }).first();
expect(Number(event.faces_last_consolidated_count)).toBe(0);
});
});
});