Skip to main content

videre_api/
faces.rs

1//! Facade over videre's faces-labeling read operations. Plain functions over
2//! an open `rusqlite::Connection`, returning serde types and a shared
3//! `Error`. Called by the axum `--faces` server and any other embedder.
4
5use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
8use std::collections::{BTreeSet, HashMap};
9use videre_core::face_db::load_face_observations;
10use videre_core::face_learning::{
11    active_question_context, append_event_batch_in_transaction, extract_cluster_quality_features,
12    extract_membership_features, finish_question_in_transaction,
13    invalidate_identity_for_removal_in_transaction, learning_state, list_learning_events,
14    list_pending_questions, question_evidence_revision, replace_pending_questions,
15    select_questions, stored_question, DecisionStage, EventFaceRef, EventFaceRole, LearningAction,
16    LearningDecisionKind, LearningOutcome, NewLearningEvent, QuestionAnswer,
17    QuestionSelectionConfig, QuestionStatus,
18};
19
20const MAX_MEMBERSHIP_EVENTS_PER_ACTION: usize = 8;
21const MAX_SUPPORT_FACES: usize = 8;
22
23#[derive(Debug)]
24struct FaceState {
25    id: i64,
26    cluster_id: Option<i64>,
27    person_label: Option<String>,
28    confirmed: bool,
29}
30
31fn immediate_transaction<T>(conn: &Connection, operation: impl FnOnce() -> Result<T>) -> Result<T> {
32    conn.execute_batch("BEGIN IMMEDIATE")?;
33    match operation() {
34        Ok(value) => match conn.execute_batch("COMMIT") {
35            Ok(()) => Ok(value),
36            Err(error) => {
37                let _ = conn.execute_batch("ROLLBACK");
38                Err(error.into())
39            }
40        },
41        Err(error) => {
42            let _ = conn.execute_batch("ROLLBACK");
43            Err(error)
44        }
45    }
46}
47
48fn face_states(conn: &Connection, face_ids: &[i64]) -> Result<Vec<FaceState>> {
49    if face_ids.is_empty()
50        || face_ids.iter().copied().collect::<BTreeSet<_>>().len() != face_ids.len()
51    {
52        return Err(Error::Invalid);
53    }
54    let mut ids = face_ids.to_vec();
55    ids.sort_unstable();
56    let mut statement =
57        conn.prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id = ?1")?;
58    ids.into_iter()
59        .map(|id| {
60            statement
61                .query_row([id], |row| {
62                    Ok(FaceState {
63                        id,
64                        cluster_id: row.get(0)?,
65                        person_label: row.get(1)?,
66                        confirmed: row.get::<_, i64>(2)? != 0,
67                    })
68                })
69                .map_err(|error| match error {
70                    rusqlite::Error::QueryReturnedNoRows => Error::NotFound,
71                    other => other.into(),
72                })
73        })
74        .collect()
75}
76
77fn unassigned_cluster_ids(conn: &Connection, cluster_id: i64) -> Result<Vec<i64>> {
78    let mut statement = conn.prepare(
79        "SELECT id FROM faces
80         WHERE cluster_id = ?1 AND confirmed = 0 AND person_label IS NULL
81         ORDER BY id",
82    )?;
83    let ids = statement
84        .query_map([cluster_id], |row| row.get(0))?
85        .collect::<rusqlite::Result<_>>()?;
86    Ok(ids)
87}
88
89fn person_support_ids(conn: &Connection, identity: &str, excluded: &[i64]) -> Result<Vec<i64>> {
90    let excluded: BTreeSet<_> = excluded.iter().copied().collect();
91    let mut statement = conn.prepare(
92        "SELECT id FROM faces
93         WHERE person_label = ?1 AND confirmed = 1 AND cluster_id IS NULL
94         ORDER BY is_primary DESC, id ASC",
95    )?;
96    let ids = statement
97        .query_map([identity], |row| row.get(0))?
98        .collect::<rusqlite::Result<Vec<i64>>>()?
99        .into_iter()
100        .filter(|id| !excluded.contains(id))
101        .take(MAX_SUPPORT_FACES)
102        .collect();
103    Ok(ids)
104}
105
106fn event_faces(subject: &[i64], support: &[i64], support_role: EventFaceRole) -> Vec<EventFaceRef> {
107    subject
108        .iter()
109        .enumerate()
110        .map(|(ordinal, face_id)| EventFaceRef {
111            face_id: *face_id,
112            role: EventFaceRole::Subject,
113            ordinal: ordinal as u32,
114        })
115        .chain(
116            support
117                .iter()
118                .enumerate()
119                .map(|(ordinal, face_id)| EventFaceRef {
120                    face_id: *face_id,
121                    role: support_role,
122                    ordinal: ordinal as u32,
123                }),
124        )
125        .collect()
126}
127
128fn membership_event(
129    conn: &Connection,
130    subject_ids: &[i64],
131    support_ids: &[i64],
132    action: LearningAction,
133    outcome: LearningOutcome,
134    target_identity: Option<String>,
135    context: &TeachingContext,
136    stage: DecisionStage,
137) -> Result<NewLearningEvent> {
138    let subject = load_face_observations(conn, subject_ids)?;
139    let support = load_face_observations(conn, support_ids)?;
140    Ok(NewLearningEvent {
141        action,
142        decision_kind: LearningDecisionKind::Membership,
143        outcome,
144        embedding_model_id: context.embedding_model_id.clone(),
145        active_profile_id: context.active_profile_id,
146        target_identity,
147        features: extract_membership_features(&subject, &support, stage)?,
148        support_count: support.len() as u32,
149        scorer_confidence: None,
150        faces: event_faces(subject_ids, support_ids, EventFaceRole::TargetSupport),
151    })
152}
153
154fn cluster_event(
155    conn: &Connection,
156    face_ids: &[i64],
157    action: LearningAction,
158    outcome: LearningOutcome,
159    target_identity: Option<String>,
160    context: &TeachingContext,
161) -> Result<NewLearningEvent> {
162    let cluster = load_face_observations(conn, face_ids)?;
163    Ok(NewLearningEvent {
164        action,
165        decision_kind: LearningDecisionKind::ClusterQuality,
166        outcome,
167        embedding_model_id: context.embedding_model_id.clone(),
168        active_profile_id: context.active_profile_id,
169        target_identity,
170        features: extract_cluster_quality_features(&cluster, DecisionStage::GalleryCluster)?,
171        support_count: cluster.len() as u32,
172        scorer_confidence: None,
173        faces: face_ids
174            .iter()
175            .enumerate()
176            .map(|(ordinal, face_id)| EventFaceRef {
177                face_id: *face_id,
178                role: EventFaceRole::ClusterMember,
179                ordinal: ordinal as u32,
180            })
181            .collect(),
182    })
183}
184
185/// Whether detection has ever run against this library.
186fn faces_table_exists(conn: &Connection) -> bool {
187    conn.query_row(
188        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='faces'",
189        [],
190        |r| r.get::<_, i64>(0),
191    )
192    .map(|n| n > 0)
193    .unwrap_or(false)
194}
195
196/// People / unassigned clusters / singletons for the labeling page.
197///
198/// :warning: **A library that has never run `videre faces` has no `faces` table
199/// at all**, and that is not an error. `videre scan` creates `file_hashes`,
200/// `people` and `pipeline_runs`; the faces table arrives with the first
201/// detection run. Querying it before then failed with "no such table", which the
202/// server turned into a 500 with an empty body, which the page turned into
203/// `Unexpected end of JSON input` across the top of the labeling UI.
204///
205/// "Nothing detected yet" is a state, not a failure. It returns empty here and
206/// the page says so.
207pub fn faces_list(conn: &Connection) -> Result<FacesData> {
208    if !faces_table_exists(conn) {
209        return Ok(FacesData::default());
210    }
211    let mut people: HashMap<String, PersonData> = HashMap::new();
212    {
213        let mut stmt = conn.prepare(
214            // LEFT JOIN, not JOIN: a face labelled before the people table
215            // existed still has to appear, showing its raw label until the
216            // migration gives it a row.
217            "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
218             FROM faces f LEFT JOIN people p ON p.name = f.person_label \
219             WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
220             ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
221        )?;
222        let rows = stmt.query_map([], |r| {
223            Ok((
224                r.get::<_, i64>(0)?,
225                r.get::<_, String>(1)?,
226                r.get::<_, String>(2)?,
227                r.get::<_, String>(3)?,
228            ))
229        })?;
230        for row in rows {
231            let (id, hash, label, full_name) = row?;
232            let person = people.entry(label.clone()).or_insert(PersonData {
233                label: label.clone(),
234                full_name,
235                face_ids: vec![],
236                representative_id: id,
237                hashes: vec![],
238            });
239            person.face_ids.push(id);
240            if !person.hashes.contains(&hash) {
241                person.hashes.push(hash);
242            }
243        }
244    }
245
246    let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
247    {
248        let mut stmt = conn.prepare(
249            "SELECT id, hash, cluster_id FROM faces \
250             WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
251             ORDER BY cluster_id, id",
252        )?;
253        let rows = stmt.query_map([], |r| {
254            Ok((
255                r.get::<_, i64>(0)?,
256                r.get::<_, String>(1)?,
257                r.get::<_, i64>(2)?,
258            ))
259        })?;
260        for row in rows {
261            let (id, hash, cid) = row?;
262            let cluster = cluster_map.entry(cid).or_insert(ClusterData {
263                cluster_id: cid,
264                face_ids: vec![],
265                hashes: vec![],
266            });
267            cluster.face_ids.push(id);
268            if !cluster.hashes.contains(&hash) {
269                cluster.hashes.push(hash);
270            }
271        }
272    }
273
274    let mut singletons: Vec<SingletonData> = vec![];
275    {
276        let mut stmt = conn.prepare(
277            "SELECT id, hash FROM faces \
278             WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
279             ORDER BY id",
280        )?;
281        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
282        for row in rows {
283            let (id, hash) = row?;
284            singletons.push(SingletonData { face_id: id, hash });
285        }
286    }
287
288    // Both maps are HashMaps, whose iteration order is arbitrary and differs
289    // between instances, so collecting straight from them threw away the
290    // ORDER BY the queries above establish. The labeling UI re-fetches this
291    // list after every assignment, so the effect was that people and clusters
292    // reshuffled on each drop: the cluster lined up next moved somewhere else,
293    // and so did the person being dragged onto. `singletons` never had the
294    // problem, and the difference is exactly that it is built as a Vec.
295    //
296    // Clusters are ordered largest first, which is the order people label in:
297    // the big clusters are worth the most and are the easiest to recognise.
298    // cluster_id breaks ties so the order is total, not merely sorted.
299    let mut people: Vec<PersonData> = people.into_values().collect();
300    people.sort_by_key(|a| a.full_name.to_lowercase());
301    let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
302    clusters.sort_by(|a, b| {
303        b.face_ids
304            .len()
305            .cmp(&a.face_ids.len())
306            .then(a.cluster_id.cmp(&b.cluster_id))
307    });
308
309    Ok(FacesData {
310        people,
311        clusters,
312        singletons,
313    })
314}
315
316/// Every face in one unassigned cluster (for the cluster detail page).
317pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
318    // Same unlabeled filter the cluster card uses: the page a card opens
319    // must show the population the card counted. A labeled face can hold no
320    // cluster id any more; the filter stays so the two queries cannot drift.
321    let mut stmt = conn.prepare(
322        "SELECT f.id, f.hash, fh.path FROM faces f \
323         JOIN file_hashes fh ON f.hash = fh.hash \
324         WHERE f.cluster_id = ?1 AND (f.confirmed = 0 OR f.person_label IS NULL) \
325         ORDER BY f.id",
326    )?;
327    let faces = stmt
328        .query_map([cluster_id], |r| {
329            Ok(ClusterFaceData {
330                face_id: r.get(0)?,
331                hash: r.get(1)?,
332                path: r.get(2)?,
333            })
334        })?
335        .collect::<rusqlite::Result<Vec<_>>>()?;
336    Ok(ClusterDetail { cluster_id, faces })
337}
338
339/// Every confirmed face for one person, primary first and flagged.
340pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
341    // Reads normalize too, so `/people/person/Erhan`, `/people/person/erhan` and the original
342    // spelling all reach the same person. That is what keeps existing links
343    // working across the migration without a redirect table.
344    let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
345    let name = name.as_str();
346    let mut stmt = conn.prepare(
347        "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
348         JOIN file_hashes fh ON f.hash = fh.hash \
349         WHERE f.person_label = ?1 AND f.confirmed = 1 \
350         ORDER BY f.is_primary DESC, f.id",
351    )?;
352    let faces = stmt
353        .query_map([name], |r| {
354            Ok(PersonFaceData {
355                face_id: r.get(0)?,
356                hash: r.get(1)?,
357                path: r.get(2)?,
358                is_primary: r.get::<_, i64>(3)? != 0,
359            })
360        })?
361        .collect::<rusqlite::Result<Vec<_>>>()?;
362    // Falls back to the identity for a person with no row yet, so a library
363    // opened before the migration still shows something sensible.
364    let full_name: String = conn
365        .query_row(
366            "SELECT full_name FROM people WHERE name = ?1",
367            rusqlite::params![name],
368            |r| r.get(0),
369        )
370        .unwrap_or_else(|_| name.to_string());
371    Ok(PersonDetail {
372        label: name.to_string(),
373        full_name,
374        faces,
375    })
376}
377
378/// Image paths for confirmed faces of a person (prefix match), for the
379/// person-name autocomplete. Delegates to the existing core search.
380pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
381    Ok(videre_core::person_search::search_by_person(
382        conn, name, None,
383    )?)
384}
385
386/// Assign faces to an existing/new person: sets person_label + confirmed.
387/// Rejects an empty label after sanitizing.
388pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
389    // What was typed becomes the display name; its normalized form is the
390    // identity written to every face row. Upserting keeps `people` complete
391    // without a separate "create person" step.
392    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
393    let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
394    // Nothing to assign is a malformed request, not a silent success that would
395    // create a person with no faces.
396    if face_ids.is_empty() {
397        return Err(Error::Invalid);
398    }
399    // All-or-nothing: a face id that matches no row makes the whole assign a
400    // NotFound, and the person insert is rolled back with it so a failed assign
401    // leaves nothing behind. An `UPDATE` matching no row is `Ok(0)`, not an
402    // error, so a partial write would otherwise be reported as success.
403    conn.execute_batch("BEGIN")?;
404    let result = assign_in_transaction(conn, face_ids, &label, &display);
405    finish_unit_transaction(conn, result)
406}
407
408fn finish_unit_transaction(conn: &Connection, result: Result<()>) -> Result<()> {
409    match result {
410        Ok(()) => {
411            if let Err(error) = conn.execute_batch("COMMIT") {
412                let _ = conn.execute_batch("ROLLBACK");
413                return Err(error.into());
414            }
415            Ok(())
416        }
417        Err(error) => {
418            let _ = conn.execute_batch("ROLLBACK");
419            Err(error)
420        }
421    }
422}
423
424fn assign_in_transaction(
425    conn: &Connection,
426    face_ids: &[i64],
427    identity: &str,
428    display: &str,
429) -> Result<()> {
430    conn.execute(
431        "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
432        rusqlite::params![identity, display],
433    )?;
434    for id in face_ids {
435        let changed = conn.execute(
436            "UPDATE faces
437             SET person_label = ?1, confirmed = 1, cluster_id = NULL
438             WHERE id = ?2",
439            rusqlite::params![identity, id],
440        )?;
441        if changed == 0 {
442            return Err(Error::NotFound);
443        }
444    }
445    Ok(())
446}
447
448fn validate_teaching_subject(conn: &Connection, face_ids: &[i64]) -> Result<Vec<FaceState>> {
449    let states = face_states(conn, face_ids)?;
450    if states
451        .iter()
452        .any(|state| state.confirmed || state.person_label.is_some())
453    {
454        return Err(Error::Invalid);
455    }
456    if states.len() == 1 && states[0].cluster_id.is_some() {
457        return Err(Error::Invalid);
458    }
459    if states.len() > 1 {
460        let cluster_id = states[0].cluster_id.ok_or(Error::Invalid)?;
461        if states
462            .iter()
463            .any(|state| state.cluster_id != Some(cluster_id))
464            || unassigned_cluster_ids(conn, cluster_id)?
465                != states.iter().map(|state| state.id).collect::<Vec<_>>()
466        {
467            return Err(Error::Invalid);
468        }
469    }
470    Ok(states)
471}
472
473fn assignment_events(
474    conn: &Connection,
475    states: &[FaceState],
476    identity: &str,
477    existing_support: &[i64],
478    context: &TeachingContext,
479    creating_person: bool,
480) -> Result<Vec<NewLearningEvent>> {
481    let ids: Vec<_> = states.iter().map(|state| state.id).collect();
482    let clustered = ids.len() > 1;
483    if !clustered && creating_person {
484        // One newly named face contains identity truth but no relationship to
485        // score. Persisting self-similarity would be a tautological positive,
486        // so this action deliberately waits for a later supported assignment.
487        load_face_observations(conn, &ids)?;
488        return Ok(Vec::new());
489    }
490    let action = match (creating_person, clustered) {
491        (true, true) => LearningAction::LabelCluster,
492        (true, false) => LearningAction::CreatePerson,
493        (false, true) => LearningAction::AssignCluster,
494        (false, false) => LearningAction::AssignFace,
495    };
496    let mut events = Vec::new();
497    if clustered {
498        events.push(cluster_event(
499            conn,
500            &ids,
501            action,
502            LearningOutcome::Positive,
503            Some(identity.to_owned()),
504            context,
505        )?);
506    }
507    if creating_person {
508        for (index, subject) in ids
509            .iter()
510            .copied()
511            .take(MAX_MEMBERSHIP_EVENTS_PER_ACTION)
512            .enumerate()
513        {
514            let support: Vec<_> = ids
515                .iter()
516                .copied()
517                .filter(|id| *id != subject)
518                .cycle()
519                .skip(index.min(ids.len().saturating_sub(1)))
520                .take(ids.len().saturating_sub(1).min(MAX_SUPPORT_FACES))
521                .collect();
522            events.push(membership_event(
523                conn,
524                &[subject],
525                &support,
526                action,
527                LearningOutcome::Positive,
528                Some(identity.to_owned()),
529                context,
530                DecisionStage::GalleryCluster,
531            )?);
532        }
533    } else if !existing_support.is_empty() {
534        for subject in ids.iter().copied().take(MAX_MEMBERSHIP_EVENTS_PER_ACTION) {
535            events.push(membership_event(
536                conn,
537                &[subject],
538                existing_support,
539                action,
540                LearningOutcome::Positive,
541                Some(identity.to_owned()),
542                context,
543                if clustered {
544                    DecisionStage::GalleryCluster
545                } else {
546                    DecisionStage::GallerySingleton
547                },
548            )?);
549        }
550    }
551    Ok(events)
552}
553
554fn assign_teaching(
555    conn: &Connection,
556    face_ids: &[i64],
557    person_label: &str,
558    context: &TeachingContext,
559    creating_person: bool,
560) -> Result<LearningAcknowledgement> {
561    if context.embedding_model_id.trim().is_empty() {
562        return Err(Error::Invalid);
563    }
564    let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
565    let identity = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
566    immediate_transaction(conn, || {
567        let states = validate_teaching_subject(conn, face_ids)?;
568        let person_exists = conn.query_row(
569            "SELECT EXISTS(SELECT 1 FROM people WHERE name = ?1)",
570            [&identity],
571            |row| row.get::<_, bool>(0),
572        )?;
573        let creating_person = creating_person && !person_exists;
574        let support = if creating_person {
575            Vec::new()
576        } else {
577            if !person_exists {
578                return Err(Error::NotFound);
579            }
580            person_support_ids(conn, &identity, face_ids)?
581        };
582        let events =
583            assignment_events(conn, &states, &identity, &support, context, creating_person)?;
584        assign_in_transaction(conn, face_ids, &identity, &display)?;
585        if events.is_empty() {
586            let state = learning_state(conn)?;
587            return Ok(LearningAcknowledgement {
588                generation: state.generation,
589                event_ids: Vec::new(),
590                message_key: "face_named_without_comparison".to_owned(),
591            });
592        }
593        let receipt = append_event_batch_in_transaction(conn, &events)?;
594        Ok(LearningAcknowledgement {
595            generation: receipt.generation,
596            event_ids: receipt.event_ids,
597            message_key: if states.len() > 1 {
598                "cluster_confirmed"
599            } else {
600                "membership_confirmed"
601            }
602            .to_owned(),
603        })
604    })
605}
606
607pub fn assign_with_learning(
608    conn: &Connection,
609    face_ids: &[i64],
610    person_label: &str,
611    context: &TeachingContext,
612) -> Result<LearningAcknowledgement> {
613    assign_teaching(conn, face_ids, person_label, context, false)
614}
615
616pub fn new_person_with_learning(
617    conn: &Connection,
618    face_ids: &[i64],
619    person_label: &str,
620    context: &TeachingContext,
621) -> Result<LearningAcknowledgement> {
622    assign_teaching(conn, face_ids, person_label, context, true)
623}
624
625/// Create a person from faces. Same effect as `assign`; kept as a distinct
626/// operation because callers treat "new person" and "assign to existing" as
627/// separate user intents.
628pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
629    assign(conn, face_ids, label)
630}
631
632/// Reset one face to fully unassigned (cluster, label, confirmed, primary).
633pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
634    // A face id from the client that matches no row is `Ok(0)`, not an error;
635    // reported as success it would tell the UI a face was reset that never
636    // existed.
637    remove_face_in_transaction(conn, face_id)
638}
639
640fn remove_face_in_transaction(conn: &Connection, face_id: i64) -> Result<()> {
641    let n = conn.execute(
642        "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
643        [face_id],
644    )?;
645    if n == 0 {
646        return Err(Error::NotFound);
647    }
648    Ok(())
649}
650
651pub fn remove_face_with_learning(
652    conn: &Connection,
653    face_id: i64,
654    context: &TeachingContext,
655) -> Result<LearningAcknowledgement> {
656    if context.embedding_model_id.trim().is_empty() {
657        return Err(Error::Invalid);
658    }
659    immediate_transaction(conn, || {
660        let state = face_states(conn, &[face_id])?.remove(0);
661        let (action, support, identity, stage) =
662            if state.confirmed && state.person_label.is_some() && state.cluster_id.is_none() {
663                let identity = state.person_label.clone().ok_or(Error::Invalid)?;
664                let support = person_support_ids(conn, &identity, &[face_id])?;
665                if support.is_empty() {
666                    remove_face_in_transaction(conn, face_id)?;
667                    let generation = learning_state(conn)?.generation;
668                    return Ok(LearningAcknowledgement {
669                        generation,
670                        event_ids: Vec::new(),
671                        message_key: "face_removed_without_comparison".to_owned(),
672                    });
673                }
674                (
675                    LearningAction::RemoveFaceFromPerson,
676                    support,
677                    Some(identity),
678                    DecisionStage::GallerySingleton,
679                )
680            } else if !state.confirmed && state.person_label.is_none() {
681                let cluster_id = state.cluster_id.ok_or(Error::Invalid)?;
682                let support: Vec<_> = unassigned_cluster_ids(conn, cluster_id)?
683                    .into_iter()
684                    .filter(|id| *id != face_id)
685                    .take(MAX_SUPPORT_FACES)
686                    .collect();
687                if support.is_empty() {
688                    remove_face_in_transaction(conn, face_id)?;
689                    let generation = learning_state(conn)?.generation;
690                    return Ok(LearningAcknowledgement {
691                        generation,
692                        event_ids: Vec::new(),
693                        message_key: "face_removed_without_comparison".to_owned(),
694                    });
695                }
696                (
697                    LearningAction::RemoveFaceFromCluster,
698                    support,
699                    None,
700                    DecisionStage::GalleryCluster,
701                )
702            } else {
703                return Err(Error::Invalid);
704            };
705        let event = membership_event(
706            conn,
707            &[face_id],
708            &support,
709            action,
710            LearningOutcome::Negative,
711            identity,
712            context,
713            stage,
714        )?;
715        remove_face_in_transaction(conn, face_id)?;
716        let receipt = append_event_batch_in_transaction(conn, &[event])?;
717        Ok(LearningAcknowledgement {
718            generation: receipt.generation,
719            event_ids: receipt.event_ids,
720            message_key: "membership_corrected".to_owned(),
721        })
722    })
723}
724
725/// Ungroup a bad cluster: its faces become unassigned singletons (not deleted).
726pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
727    // A cluster id from the client that matches no row is `Ok(0)`, not an error;
728    // reported as success it would tell the UI a cluster was ungrouped that
729    // never existed.
730    dissolve_cluster_in_transaction(conn, cluster_id)
731}
732
733fn dissolve_cluster_in_transaction(conn: &Connection, cluster_id: i64) -> Result<()> {
734    let n = conn.execute(
735        "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
736        [cluster_id],
737    )?;
738    if n == 0 {
739        return Err(Error::NotFound);
740    }
741    Ok(())
742}
743
744pub fn dissolve_cluster_with_learning(
745    conn: &Connection,
746    cluster_id: i64,
747    context: &TeachingContext,
748) -> Result<LearningAcknowledgement> {
749    if context.embedding_model_id.trim().is_empty() {
750        return Err(Error::Invalid);
751    }
752    immediate_transaction(conn, || {
753        let face_ids = unassigned_cluster_ids(conn, cluster_id)?;
754        let all_faces: i64 = conn.query_row(
755            "SELECT COUNT(*) FROM faces WHERE cluster_id = ?1",
756            [cluster_id],
757            |row| row.get(0),
758        )?;
759        if all_faces != face_ids.len() as i64 {
760            return Err(Error::Invalid);
761        }
762        if face_ids.len() < 2 {
763            return if face_ids.is_empty() {
764                Err(Error::NotFound)
765            } else {
766                Err(Error::Invalid)
767            };
768        }
769        let event = cluster_event(
770            conn,
771            &face_ids,
772            LearningAction::DissolveCluster,
773            LearningOutcome::Negative,
774            None,
775            context,
776        )?;
777        dissolve_cluster_in_transaction(conn, cluster_id)?;
778        let receipt = append_event_batch_in_transaction(conn, &[event])?;
779        Ok(LearningAcknowledgement {
780            generation: receipt.generation,
781            event_ids: receipt.event_ids,
782            message_key: "cluster_dissolved".to_owned(),
783        })
784    })
785}
786
787/// Reset every face of a person back to unassigned. Deliberately does NOT touch
788/// cluster_id, so a face rejoins its cluster's unassigned group rather than
789/// scattering to singletons.
790/// Change only what a person is shown as, never their identity.
791///
792/// This is the only rename there is. Identity is permanent: `Erhan` to
793/// `Erhan Gündoğan` is a display correction even though its normalized form
794/// would change too, and there is no way to ask for the other reading. One row,
795/// no face touched, and `/people/person/<name>` keeps working, which is the whole
796/// reason identity and display are separate.
797pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
798    let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
799    let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
800    let n = conn.execute(
801        "UPDATE people SET full_name = ?1 WHERE name = ?2",
802        rusqlite::params![display, name],
803    )?;
804    if n == 0 {
805        return Err(Error::NotFound);
806    }
807    Ok(())
808}
809
810pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
811    let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
812    // One transaction: the faces either come back unassigned AND the
813    // regroup gate reopens, or nothing changes at all.
814    conn.execute_batch("BEGIN")?;
815    let result = delete_person_in_transaction(conn, &label).map(|_| ());
816    finish_unit_transaction(conn, result)
817}
818
819fn delete_person_in_transaction(conn: &Connection, identity: &str) -> Result<usize> {
820    let changed = conn.execute(
821        "UPDATE faces
822         SET person_label = NULL, confirmed = 0, is_primary = 0, cluster_id = NULL
823         WHERE person_label = ?1",
824        [identity],
825    )?;
826    if changed > 0 {
827        videre_core::library_state::set(
828            conn,
829            videre_core::library_state::FACE_RECLUSTER_WATERMARK,
830            0,
831        )?;
832    }
833    Ok(changed)
834}
835
836pub fn delete_person_with_learning(
837    conn: &Connection,
838    label: &str,
839) -> Result<Option<LearningAcknowledgement>> {
840    let identity = videre_core::person::normalize(label).ok_or(Error::Invalid)?;
841    immediate_transaction(conn, || {
842        let changed = delete_person_in_transaction(conn, &identity)?;
843        if changed == 0 {
844            return Ok(None);
845        }
846        let generation = invalidate_identity_for_removal_in_transaction(conn, &identity)?;
847        Ok(Some(LearningAcknowledgement {
848            generation,
849            event_ids: Vec::new(),
850            message_key: "person_removed".to_owned(),
851        }))
852    })
853}
854
855/// Answer one pending identity question. Yes confirms the subject cluster as
856/// the target person and teaches one positive membership; No teaches one
857/// negative membership without labeling; Skip only changes delivery state.
858/// Every answer revalidates subject, target, active profile, and evidence
859/// revision inside the transaction. Drift supersedes the stale question and
860/// returns Conflict without writing a label or teaching event.
861pub fn answer_question_with_learning(
862    conn: &Connection,
863    question_id: i64,
864    answer: QuestionAnswer,
865    context: &TeachingContext,
866) -> Result<QuestionAnswerOutcome> {
867    if context.embedding_model_id.trim().is_empty() {
868        return Err(Error::Invalid);
869    }
870    let outcome = immediate_transaction(conn, || {
871        let question = stored_question(conn, question_id)?;
872        let question = match question {
873            Some(question) if question.status == QuestionStatus::Pending => question,
874            _ => return Err(Error::NotFound),
875        };
876        let supersede = || {
877            finish_question_in_transaction(conn, question_id, QuestionStatus::Superseded)?;
878            Ok(None)
879        };
880        let states = match face_states(conn, &question.subject_face_ids) {
881            Ok(states) => states,
882            Err(Error::NotFound) => return supersede(),
883            Err(error) => return Err(error),
884        };
885        if states
886            .iter()
887            .any(|state| state.confirmed || state.person_label.is_some())
888        {
889            return supersede();
890        }
891        // Every subject face must still sit in the cluster the question was
892        // built from; a recluster that moved any of them invalidates the
893        // evidence and the question.
894        if states
895            .iter()
896            .any(|state| state.cluster_id != Some(question.cluster_id))
897        {
898            return supersede();
899        }
900        let display: String = match conn.query_row(
901            "SELECT full_name FROM people WHERE name = ?1",
902            [&question.target_identity],
903            |row| row.get(0),
904        ) {
905            Ok(display) => display,
906            Err(rusqlite::Error::QueryReturnedNoRows) => return supersede(),
907            Err(error) => return Err(error.into()),
908        };
909        let active = active_question_context(conn)?;
910        let Some(active) = active else {
911            return supersede();
912        };
913        if active.profile_id != question.profile_id || active.model_kind != question.model_kind {
914            return supersede();
915        }
916        let representative: i64 = match conn.query_row(
917            "SELECT f.id FROM faces AS f
918                 JOIN face_learning_question_faces AS qf
919                   ON qf.face_id = f.id AND qf.question_id = ?1 AND qf.role = 'subject'
920                 WHERE f.confirmed = 0 AND f.person_label IS NULL
921                 ORDER BY f.is_primary DESC, f.det_score DESC, f.id ASC
922                 LIMIT 1",
923            [question_id],
924            |row| row.get(0),
925        ) {
926            Ok(representative) => representative,
927            Err(rusqlite::Error::QueryReturnedNoRows) => return supersede(),
928            Err(error) => return Err(error.into()),
929        };
930        let support = person_support_ids(conn, &question.target_identity, &[])?;
931        let subject_observation = load_face_observations(conn, &[representative])?;
932        let support_observation = load_face_observations(conn, &support)?;
933        let features = extract_membership_features(
934            &subject_observation,
935            &support_observation,
936            DecisionStage::Question,
937        )?;
938        let revision = question_evidence_revision(
939            question.profile_id,
940            question.model_kind.as_str(),
941            &question.subject_face_ids,
942            &question.target_identity,
943            &features,
944            active.membership_threshold,
945            &support,
946        );
947        if revision != question.evidence_revision {
948            return supersede();
949        }
950        match answer {
951            QuestionAnswer::Skip => {
952                finish_question_in_transaction(conn, question_id, QuestionStatus::Skipped)?;
953                Ok(Some(QuestionAnswerOutcome {
954                    status: "skipped".into(),
955                    acknowledgement: None,
956                }))
957            }
958            QuestionAnswer::Yes => {
959                assign_in_transaction(
960                    conn,
961                    &question.subject_face_ids,
962                    &question.target_identity,
963                    &display,
964                )?;
965                let event = membership_event(
966                    conn,
967                    &[representative],
968                    &support,
969                    LearningAction::QuestionYes,
970                    LearningOutcome::Positive,
971                    Some(question.target_identity.clone()),
972                    context,
973                    DecisionStage::Question,
974                )?;
975                let receipt = append_event_batch_in_transaction(conn, &[event])?;
976                finish_question_in_transaction(conn, question_id, QuestionStatus::Answered)?;
977                Ok(Some(QuestionAnswerOutcome {
978                    status: "answered".into(),
979                    acknowledgement: Some(LearningAcknowledgement {
980                        generation: receipt.generation,
981                        event_ids: receipt.event_ids,
982                        message_key: "question_confirmed".into(),
983                    }),
984                }))
985            }
986            QuestionAnswer::No => {
987                let event = membership_event(
988                    conn,
989                    &[representative],
990                    &support,
991                    LearningAction::QuestionNo,
992                    LearningOutcome::Negative,
993                    Some(question.target_identity.clone()),
994                    context,
995                    DecisionStage::Question,
996                )?;
997                let receipt = append_event_batch_in_transaction(conn, &[event])?;
998                finish_question_in_transaction(conn, question_id, QuestionStatus::Answered)?;
999                Ok(Some(QuestionAnswerOutcome {
1000                    status: "answered".into(),
1001                    acknowledgement: Some(LearningAcknowledgement {
1002                        generation: receipt.generation,
1003                        event_ids: receipt.event_ids,
1004                        message_key: "question_corrected".into(),
1005                    }),
1006                }))
1007            }
1008        }
1009    })?;
1010    outcome.ok_or(Error::Conflict)
1011}
1012
1013/// Pending identity questions for the gallery page, bounded and in priority
1014/// order. Selection never mutates anything.
1015pub fn pending_identity_questions(
1016    conn: &Connection,
1017    limit: usize,
1018) -> Result<Vec<videre_core::face_learning::StoredQuestion>> {
1019    Ok(list_pending_questions(conn, limit)?)
1020}
1021
1022/// Refresh the pending question page from the active profile. Runs outside
1023/// request handling; safe to call whenever training promotes a profile.
1024pub fn refresh_identity_questions(
1025    conn: &Connection,
1026    config: &QuestionSelectionConfig,
1027) -> Result<Vec<videre_core::face_learning::StoredQuestion>> {
1028    videre_core::face_learning::ensure_question_tables(conn)?;
1029    let candidates = select_questions(conn, config)?;
1030    Ok(replace_pending_questions(conn, &candidates)?)
1031}
1032
1033/// Learning state plus pending question volume for the status resource.
1034pub fn face_learning_status(conn: &Connection) -> Result<FaceLearningStatus> {
1035    videre_core::face_learning::ensure_learning_tables(conn)?;
1036    videre_core::face_learning::ensure_question_tables(conn)?;
1037    let state = videre_core::face_learning::learning_state(conn)?;
1038    let pending_questions = conn.query_row(
1039        "SELECT count(*) FROM face_learning_questions WHERE status = 'pending'",
1040        [],
1041        |row| row.get::<_, i64>(0),
1042    )?;
1043    // A retired profile was promoted and later replaced; it still counts as
1044    // the run having promoted.
1045    let last_candidate = match state.last_profile_id {
1046        Some(id) => {
1047            videre_core::face_learning::ensure_profile_table(conn)?;
1048            conn.query_row(
1049                "SELECT status FROM face_learning_profiles WHERE id = ?1",
1050                [id],
1051                |row| row.get::<_, String>(0),
1052            )
1053            .map(Some)
1054            .or_else(|error| match error {
1055                rusqlite::Error::QueryReturnedNoRows => Ok(None),
1056                other => Err(other),
1057            })?
1058            .and_then(|status| match status.as_str() {
1059                "active" | "retired" => Some("promoted".to_string()),
1060                "rejected" => Some("rejected".to_string()),
1061                _ => None,
1062            })
1063        }
1064        None => None,
1065    };
1066    Ok(FaceLearningStatus {
1067        generation: state.generation,
1068        trained_generation: state.trained_generation,
1069        status: format!("{:?}", state.status).to_lowercase(),
1070        last_profile_id: state.last_profile_id,
1071        last_candidate,
1072        last_error: state.last_error,
1073        pending_questions: pending_questions as usize,
1074    })
1075}
1076
1077/// One journal entry plus read-time proof facts. `source_available` says
1078/// whether every referenced face still exists; `incompatible` says whether
1079/// the entry can no longer feed training. Both are computed at read time and
1080/// never rewrite the historical row.
1081#[derive(Debug, Clone, serde::Serialize)]
1082pub struct FaceLearningEventProof {
1083    #[serde(flatten)]
1084    pub event: videre_core::face_learning::StoredLearningEvent,
1085    pub source_available: bool,
1086    pub incompatible: bool,
1087}
1088
1089fn proof_for(
1090    conn: &Connection,
1091    event: videre_core::face_learning::StoredLearningEvent,
1092    current_embedding_model_id: Option<&str>,
1093) -> Result<FaceLearningEventProof> {
1094    let mut source_available = true;
1095    for face in &event.faces {
1096        let exists: bool = conn.query_row(
1097            "SELECT EXISTS(SELECT 1 FROM faces WHERE id = ?1)",
1098            [face.face_id],
1099            |row| row.get(0),
1100        )?;
1101        if !exists {
1102            source_available = false;
1103            break;
1104        }
1105    }
1106    let incompatible = event.features.schema_version
1107        != videre_core::face_learning::FEATURE_SCHEMA_VERSION
1108        || current_embedding_model_id.is_some_and(|model| model != event.embedding_model_id);
1109    Ok(FaceLearningEventProof {
1110        event,
1111        source_available,
1112        incompatible,
1113    })
1114}
1115
1116/// Learning events, newest first. Payloads carry scalar feature snapshots and
1117/// provenance ids only; embeddings never leave the library.
1118pub fn face_learning_events(
1119    conn: &Connection,
1120    limit: usize,
1121    before_id: Option<i64>,
1122    current_embedding_model_id: Option<&str>,
1123) -> Result<Vec<FaceLearningEventProof>> {
1124    videre_core::face_learning::ensure_learning_tables(conn)?;
1125    let limit = limit.clamp(1, 200);
1126    let events = list_learning_events(conn, limit, before_id)?;
1127    events
1128        .into_iter()
1129        .map(|event| proof_for(conn, event, current_embedding_model_id))
1130        .collect()
1131}
1132
1133pub fn face_learning_event(
1134    conn: &Connection,
1135    event_id: i64,
1136    current_embedding_model_id: Option<&str>,
1137) -> Result<Option<FaceLearningEventProof>> {
1138    videre_core::face_learning::ensure_learning_tables(conn)?;
1139    match videre_core::face_learning::learning_event(conn, event_id)? {
1140        Some(event) => Ok(Some(proof_for(conn, event, current_embedding_model_id)?)),
1141        None => Ok(None),
1142    }
1143}
1144
1145/// Load the immutable training inputs for the learning worker's snapshot.
1146pub fn load_training_snapshot(
1147    conn: &Connection,
1148    embedding_model_id: &str,
1149    generation: u64,
1150    config: &videre_core::face_learning::TrainingConfig,
1151) -> std::result::Result<videre_core::face_learning::TrainingSnapshot, String> {
1152    let labels =
1153        videre_core::face_db::load_confirmed_face_labels(conn).map_err(|e| e.to_string())?;
1154    let face_ids: Vec<i64> = {
1155        let mut statement = conn
1156            .prepare("SELECT id FROM faces ORDER BY id")
1157            .map_err(|e| e.to_string())?;
1158        let rows = statement
1159            .query_map([], |row| row.get(0))
1160            .map_err(|e| e.to_string())?
1161            .collect::<rusqlite::Result<Vec<i64>>>()
1162            .map_err(|e| e.to_string())?;
1163        rows
1164    };
1165    let observations =
1166        videre_core::face_db::load_face_observations(conn, &face_ids).map_err(|e| e.to_string())?;
1167    let events = videre_core::face_learning::eligible_events_for_training(
1168        conn,
1169        embedding_model_id,
1170        videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1171    )
1172    .map_err(|e| e.to_string())?;
1173    videre_core::face_learning::build_training_snapshot(
1174        generation,
1175        embedding_model_id,
1176        &labels,
1177        &observations,
1178        &events,
1179        config,
1180    )
1181    .map_err(|e| e.to_string())
1182}
1183
1184/// Persist a trained candidate: insert, promote through the shipped gates,
1185/// and return the profile identity and verdict. The profile row keeps the
1186/// promotion outcome either way.
1187pub fn persist_trained_profile(
1188    conn: &Connection,
1189    embedding_model_id: &str,
1190    run: &videre_core::face_learning::TrainingRun,
1191    gates: &videre_core::face_learning::PromotionGates,
1192) -> Result<TrainedProfileSummary> {
1193    let validation = match run.comparison.selected {
1194        videre_core::face_learning::CandidateKind::Logistic => &run.logistic_validation,
1195        videre_core::face_learning::CandidateKind::Additive => &run.additive_validation,
1196    };
1197    let profile = videre_core::face_learning::NewProfile {
1198        artifact_version: videre_core::face_learning::PROFILE_ARTIFACT_VERSION,
1199        embedding_model_id: embedding_model_id.to_owned(),
1200        feature_schema_version: videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1201        model_kind: run.selected.model_kind().to_owned(),
1202        parameters: serde_json::to_vec(&run.selected).map_err(Error::from)?,
1203        training_evidence: run.evidence_counts.clone(),
1204        validation_report: validation.clone(),
1205        stage: videre_core::face_learning::ProfileStage::Suggestion,
1206    };
1207    let profile_id = videre_core::face_learning::insert_candidate(conn, &profile)?;
1208    let outcome = videre_core::face_learning::evaluate_and_promote(conn, profile_id, gates)?;
1209    Ok(TrainedProfileSummary {
1210        profile_id,
1211        model_kind: profile.model_kind,
1212        promoted: outcome == videre_core::face_learning::PromotionOutcome::Promoted,
1213    })
1214}
1215
1216/// Mark one face as the person's primary (their labeling-page thumbnail),
1217/// clearing any previous primary in the same transaction so exactly one
1218/// remains. The target update is guarded by person_label so it can't steal a
1219/// face from another person.
1220pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
1221    let person_label =
1222        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
1223    conn.execute_batch("BEGIN")?;
1224    let result = (|| -> Result<()> {
1225        conn.execute(
1226            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
1227            rusqlite::params![person_label],
1228        )?;
1229        // The guard on person_label means a face id that does not exist, or
1230        // belongs to someone else, matches no row: `Ok(0)`, not an error. That
1231        // is a NotFound, and the rollback restores the primary cleared above so
1232        // a failed call leaves the person's primary untouched.
1233        let n = conn.execute(
1234            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
1235            rusqlite::params![person_label, face_id],
1236        )?;
1237        if n == 0 {
1238            return Err(Error::NotFound);
1239        }
1240        Ok(())
1241    })();
1242    match result {
1243        Ok(()) => {
1244            conn.execute_batch("COMMIT")?;
1245            Ok(())
1246        }
1247        Err(e) => {
1248            let _ = conn.execute_batch("ROLLBACK");
1249            Err(e)
1250        }
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::*;
1257
1258    #[test]
1259    fn assign_detaches_the_face_from_its_cluster() {
1260        let conn = seed();
1261        // Face 3 sits in cluster 7 (unassigned). Assigning it to a person
1262        // must detach the machine grouping in the same write.
1263        assign(&conn, &[3], "Bob").unwrap();
1264        let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
1265            .query_row(
1266                "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
1267                [],
1268                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1269            )
1270            .unwrap();
1271        assert_eq!(label.as_deref(), Some("bob"));
1272        assert_eq!(confirmed, 1);
1273        assert_eq!(cid, None, "assignment must detach the machine grouping");
1274    }
1275
1276    #[test]
1277    fn cluster_detail_never_shows_labeled_faces() {
1278        let conn = seed();
1279        // A tombstone the detach migration should have cleared: a labeled
1280        // face still carrying a cluster id. The filter keeps the detail
1281        // page from ever showing it, whatever wrote that row.
1282        conn.execute(
1283            "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
1284                (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
1285            [],
1286        )
1287        .unwrap();
1288        conn.execute(
1289            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
1290            [],
1291        )
1292        .unwrap();
1293        let detail = cluster_detail(&conn, 7).unwrap();
1294        assert_eq!(
1295            detail.faces.len(),
1296            2,
1297            "only the unlabeled faces of cluster 7 belong on the page"
1298        );
1299    }
1300
1301    /// In-memory db with the faces + file_hashes tables and a few rows:
1302    /// - face 1: person "Alice", confirmed, is_primary
1303    /// - face 2: person "Alice", confirmed
1304    /// - face 3: cluster 7 (unassigned)
1305    /// - face 4: cluster 7 (unassigned)
1306    /// - face 5: singleton (no cluster, unassigned)
1307    pub(super) fn seed() -> Connection {
1308        let conn = Connection::open_in_memory().unwrap();
1309        videre_core::face_db::create_faces_table(&conn).unwrap();
1310        conn.execute_batch(
1311            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1312             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
1313                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
1314             -- Labels are stored in identity form, as `assign` writes them and
1315             -- as the migration leaves them; `people` carries what a reader
1316             -- sees. Seeding raw 'Alice' would test a state the application no
1317             -- longer produces.
1318             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
1319             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1320                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
1321                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
1322                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
1323                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
1324                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
1325        )
1326        .unwrap();
1327        videre_core::db::ensure_file_hashes_columns(&conn);
1328        conn
1329    }
1330
1331    mod learning {
1332        use super::*;
1333        use videre_core::face_learning::{
1334            learning_state, list_learning_events, LearningAction, LearningDecisionKind,
1335            LearningOutcome,
1336        };
1337
1338        fn context() -> TeachingContext {
1339            TeachingContext {
1340                embedding_model_id: "buffalo_l/w600k_r50.onnx".to_owned(),
1341                active_profile_id: None,
1342            }
1343        }
1344
1345        fn embedding(x: u16, y: u16) -> Vec<u8> {
1346            [x.to_le_bytes(), y.to_le_bytes()].concat()
1347        }
1348
1349        fn learning_seed() -> Connection {
1350            let conn = Connection::open_in_memory().unwrap();
1351            videre_core::face_db::create_faces_table(&conn).unwrap();
1352            conn.execute_batch(
1353                "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1354                 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice');",
1355            )
1356            .unwrap();
1357            let rows = [
1358                (1, "a1", embedding(0x3c00, 0), None, Some("alice"), 1),
1359                (2, "a2", embedding(0x3b9a, 0x3266), None, Some("alice"), 1),
1360                (3, "c1", embedding(0x3c00, 0), Some(7), None, 0),
1361                (4, "c2", embedding(0x3b9a, 0x3266), Some(7), None, 0),
1362                (5, "c3", embedding(0x3b33, 0x34cd), Some(7), None, 0),
1363                (6, "s1", embedding(0x3266, 0x3b9a), None, None, 0),
1364                (7, "d1", embedding(0x3c00, 0), Some(9), None, 0),
1365                (8, "d2", embedding(0, 0x3c00), Some(9), None, 0),
1366            ];
1367            for (id, hash, bytes, cluster, label, confirmed) in rows {
1368                conn.execute(
1369                    "INSERT INTO file_hashes (hash, path) VALUES (?1, ?2)",
1370                    rusqlite::params![hash, format!("/p/{hash}.jpg")],
1371                )
1372                .unwrap();
1373                conn.execute(
1374                    "INSERT INTO faces
1375                     (id, hash, bbox, embedding, cluster_id, person_label, confirmed,
1376                      is_primary, det_score, blur)
1377                     VALUES (?1, ?2, '0,0,112,112', ?3, ?4, ?5, ?6, 0, 0.95, 900.0)",
1378                    rusqlite::params![id, hash, bytes, cluster, label, confirmed],
1379                )
1380                .unwrap();
1381            }
1382            conn
1383        }
1384
1385        #[test]
1386        fn learning_assignments_emit_expected_positive_evidence_once_per_action() {
1387            let conn = learning_seed();
1388
1389            let assigned = assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1390            assert_eq!(assigned.generation, 1);
1391            assert_eq!(assigned.event_ids.len(), 1);
1392
1393            let labeled = new_person_with_learning(&conn, &[3, 4, 5], "Bob", &context()).unwrap();
1394            assert_eq!(labeled.generation, 2);
1395            assert_eq!(labeled.event_ids.len(), 4);
1396
1397            let events = list_learning_events(&conn, 20, None).unwrap();
1398            assert_eq!(events.len(), 5);
1399            assert_eq!(
1400                events
1401                    .iter()
1402                    .filter(|event| event.action == LearningAction::LabelCluster
1403                        && event.decision_kind == LearningDecisionKind::ClusterQuality
1404                        && event.outcome == LearningOutcome::Positive)
1405                    .count(),
1406                1
1407            );
1408            assert_eq!(
1409                events
1410                    .iter()
1411                    .filter(
1412                        |event| event.decision_kind == LearningDecisionKind::Membership
1413                            && event.outcome == LearningOutcome::Positive
1414                    )
1415                    .count(),
1416                4
1417            );
1418            assert!(events.iter().all(|event| {
1419                let json = event.features.to_canonical_json().unwrap();
1420                !json.contains("alice") && !json.contains("bob") && !json.contains("/p/")
1421            }));
1422
1423            let conn = learning_seed();
1424            let assigned_cluster =
1425                assign_with_learning(&conn, &[3, 4, 5], "alice", &context()).unwrap();
1426            assert_eq!(assigned_cluster.generation, 1);
1427            assert_eq!(assigned_cluster.event_ids.len(), 4);
1428            let events = list_learning_events(&conn, 20, None).unwrap();
1429            assert_eq!(
1430                events
1431                    .iter()
1432                    .filter(|event| event.action == LearningAction::AssignCluster
1433                        && event.decision_kind == LearningDecisionKind::Membership)
1434                    .count(),
1435                3
1436            );
1437            assert_eq!(
1438                events
1439                    .iter()
1440                    .filter(|event| event.action == LearningAction::AssignCluster
1441                        && event.decision_kind == LearningDecisionKind::ClusterQuality)
1442                    .count(),
1443                1
1444            );
1445        }
1446
1447        #[test]
1448        fn a_large_cluster_has_a_deterministic_per_action_membership_cap() {
1449            let conn = learning_seed();
1450            for id in 10..22 {
1451                let hash = format!("large-{id}");
1452                conn.execute(
1453                    "INSERT INTO faces
1454                     (id, hash, bbox, embedding, cluster_id, confirmed, is_primary,
1455                      det_score, blur)
1456                     VALUES (?1, ?2, '0,0,112,112', ?3, 42, 0, 0, 0.95, 900.0)",
1457                    rusqlite::params![id, hash, embedding(0x3c00, (id as u16) + 0x2000)],
1458                )
1459                .unwrap();
1460            }
1461            let ids: Vec<_> = (10..22).collect();
1462            let acknowledgement =
1463                new_person_with_learning(&conn, &ids, "Large Family", &context()).unwrap();
1464            assert_eq!(
1465                acknowledgement.event_ids.len(),
1466                1 + MAX_MEMBERSHIP_EVENTS_PER_ACTION
1467            );
1468            assert_eq!(learning_state(&conn).unwrap().generation, 1);
1469
1470            let events = list_learning_events(&conn, 20, None).unwrap();
1471            assert_eq!(
1472                events
1473                    .iter()
1474                    .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1475                    .count(),
1476                MAX_MEMBERSHIP_EVENTS_PER_ACTION
1477            );
1478            assert!(events
1479                .iter()
1480                .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1481                .all(|event| event.support_count as usize <= MAX_SUPPORT_FACES));
1482        }
1483
1484        #[test]
1485        fn learning_corrections_use_pre_action_state_without_pairwise_dissolve_labels() {
1486            let conn = learning_seed();
1487
1488            let removed_cluster = remove_face_with_learning(&conn, 3, &context()).unwrap();
1489            assert_eq!(removed_cluster.generation, 1);
1490            let removed_person = remove_face_with_learning(&conn, 2, &context()).unwrap();
1491            assert_eq!(removed_person.generation, 2);
1492            let dissolved = dissolve_cluster_with_learning(&conn, 9, &context()).unwrap();
1493            assert_eq!(dissolved.generation, 3);
1494
1495            let events = list_learning_events(&conn, 20, None).unwrap();
1496            assert_eq!(events.len(), 3);
1497            assert_eq!(
1498                events
1499                    .iter()
1500                    .filter(
1501                        |event| event.decision_kind == LearningDecisionKind::Membership
1502                            && event.outcome == LearningOutcome::Negative
1503                    )
1504                    .count(),
1505                2
1506            );
1507            let dissolve = events
1508                .iter()
1509                .find(|event| event.action == LearningAction::DissolveCluster)
1510                .unwrap();
1511            assert_eq!(dissolve.decision_kind, LearningDecisionKind::ClusterQuality);
1512            assert_eq!(dissolve.outcome, LearningOutcome::Negative);
1513            assert_eq!(dissolve.faces.len(), 2);
1514        }
1515
1516        #[test]
1517        fn unsupported_last_face_removals_still_apply_without_fabricated_evidence() {
1518            let conn = learning_seed();
1519            remove_face_with_learning(&conn, 1, &context()).unwrap();
1520            let last_person_face = remove_face_with_learning(&conn, 2, &context()).unwrap();
1521            assert!(last_person_face.event_ids.is_empty());
1522            assert_eq!(last_person_face.generation, 1);
1523            let person_state: (Option<String>, i64) = conn
1524                .query_row(
1525                    "SELECT person_label, confirmed FROM faces WHERE id = 2",
1526                    [],
1527                    |row| Ok((row.get(0)?, row.get(1)?)),
1528                )
1529                .unwrap();
1530            assert_eq!(person_state, (None, 0));
1531
1532            remove_face_with_learning(&conn, 3, &context()).unwrap();
1533            remove_face_with_learning(&conn, 4, &context()).unwrap();
1534            let last_cluster_face = remove_face_with_learning(&conn, 5, &context()).unwrap();
1535            assert!(last_cluster_face.event_ids.is_empty());
1536            assert_eq!(last_cluster_face.generation, 3);
1537            let cluster_id: Option<i64> = conn
1538                .query_row("SELECT cluster_id FROM faces WHERE id = 5", [], |row| {
1539                    row.get(0)
1540                })
1541                .unwrap();
1542            assert_eq!(cluster_id, None);
1543        }
1544
1545        #[test]
1546        fn new_person_collision_uses_existing_person_support() {
1547            let conn = learning_seed();
1548            let acknowledgement =
1549                new_person_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1550            assert_eq!(acknowledgement.generation, 1);
1551            assert_eq!(acknowledgement.event_ids.len(), 1);
1552            let events = list_learning_events(&conn, 10, None).unwrap();
1553            assert_eq!(events[0].action, LearningAction::AssignFace);
1554            assert_eq!(events[0].target_identity.as_deref(), Some("alice"));
1555            assert_eq!(events[0].support_count, 2);
1556        }
1557
1558        #[test]
1559        fn assigning_to_a_face_less_person_keeps_only_supported_evidence() {
1560            let conn = learning_seed();
1561            conn.execute(
1562                "UPDATE faces
1563                 SET person_label = NULL, confirmed = 0
1564                 WHERE person_label = 'alice'",
1565                [],
1566            )
1567            .unwrap();
1568
1569            let singleton = assign_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1570            assert!(singleton.event_ids.is_empty());
1571            assert_eq!(singleton.generation, 0);
1572            assert_eq!(singleton.message_key, "face_named_without_comparison");
1573            let assigned: (Option<String>, i64) = conn
1574                .query_row(
1575                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1576                    [],
1577                    |row| Ok((row.get(0)?, row.get(1)?)),
1578                )
1579                .unwrap();
1580            assert_eq!(assigned, (Some("alice".to_owned()), 1));
1581            assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1582
1583            let conn = learning_seed();
1584            conn.execute(
1585                "UPDATE faces
1586                 SET person_label = NULL, confirmed = 0
1587                 WHERE person_label = 'alice'",
1588                [],
1589            )
1590            .unwrap();
1591            let cluster = new_person_with_learning(&conn, &[3, 4, 5], "Alice", &context()).unwrap();
1592            assert_eq!(cluster.event_ids.len(), 1);
1593            assert_eq!(cluster.generation, 1);
1594            let events = list_learning_events(&conn, 10, None).unwrap();
1595            assert_eq!(events.len(), 1);
1596            assert_eq!(events[0].action, LearningAction::AssignCluster);
1597            assert_eq!(
1598                events[0].decision_kind,
1599                LearningDecisionKind::ClusterQuality
1600            );
1601        }
1602
1603        #[test]
1604        fn event_insert_failure_rolls_back_the_visible_assignment_and_generation() {
1605            let conn = learning_seed();
1606            conn.execute_batch(
1607                "CREATE TRIGGER reject_learning_event
1608                 BEFORE INSERT ON face_learning_events
1609                 BEGIN SELECT RAISE(ABORT, 'test rejection'); END;",
1610            )
1611            .unwrap();
1612
1613            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1614            let state: (Option<String>, i64) = conn
1615                .query_row(
1616                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1617                    [],
1618                    |row| Ok((row.get(0)?, row.get(1)?)),
1619                )
1620                .unwrap();
1621            assert_eq!(state, (None, 0));
1622            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1623            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1624        }
1625
1626        #[test]
1627        fn commit_failure_rolls_back_faces_events_and_generation() {
1628            let conn = learning_seed();
1629            conn.execute_batch(
1630                "PRAGMA foreign_keys = ON;
1631                 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
1632                 CREATE TABLE commit_guard_child (
1633                     event_id INTEGER PRIMARY KEY,
1634                     parent_id INTEGER NOT NULL,
1635                     FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
1636                         DEFERRABLE INITIALLY DEFERRED
1637                 );
1638                 CREATE TRIGGER fail_learning_commit
1639                 AFTER INSERT ON face_learning_events
1640                 BEGIN
1641                     INSERT INTO commit_guard_child (event_id, parent_id)
1642                     VALUES (NEW.id, 999);
1643                 END;",
1644            )
1645            .unwrap();
1646
1647            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1648            let state: (Option<String>, i64) = conn
1649                .query_row(
1650                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1651                    [],
1652                    |row| Ok((row.get(0)?, row.get(1)?)),
1653                )
1654                .unwrap();
1655            assert_eq!(state, (None, 0));
1656            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1657            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1658        }
1659
1660        #[test]
1661        fn malformed_or_mixed_prestate_rolls_back_without_learning() {
1662            let conn = learning_seed();
1663            conn.execute("UPDATE faces SET embedding = X'0000' WHERE id = 6", [])
1664                .unwrap();
1665            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1666            assert!(new_person_with_learning(&conn, &[3, 7], "Bob", &context()).is_err());
1667            assert!(new_person_with_learning(&conn, &[1], "Bob", &context()).is_err());
1668            assert!(assign_with_learning(&conn, &[999], "alice", &context()).is_err());
1669            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1670            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1671        }
1672
1673        #[test]
1674        fn deleting_a_person_invalidates_identity_evidence_without_a_negative_event() {
1675            let conn = learning_seed();
1676            assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1677            let acknowledgement = delete_person_with_learning(&conn, "alice")
1678                .unwrap()
1679                .unwrap();
1680            assert_eq!(acknowledgement.generation, 2);
1681            assert!(acknowledgement.event_ids.is_empty());
1682
1683            let events = list_learning_events(&conn, 20, None).unwrap();
1684            assert_eq!(events.len(), 1);
1685            assert!(!events[0].eligible);
1686            assert_eq!(
1687                events[0].invalidation_reason,
1688                Some(videre_core::face_learning::InvalidationReason::PersonRemoved)
1689            );
1690            assert!(delete_person_with_learning(&conn, "alice")
1691                .unwrap()
1692                .is_none());
1693            assert_eq!(learning_state(&conn).unwrap().generation, 2);
1694        }
1695
1696        #[test]
1697        fn deleting_a_person_without_learning_evidence_keeps_generation_current() {
1698            let conn = learning_seed();
1699            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1700
1701            let acknowledgement = delete_person_with_learning(&conn, "alice")
1702                .unwrap()
1703                .unwrap();
1704
1705            assert_eq!(acknowledgement.generation, 0);
1706            assert!(acknowledgement.event_ids.is_empty());
1707            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1708            assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1709        }
1710    }
1711
1712    #[test]
1713    fn the_list_comes_back_in_the_same_order_every_time() {
1714        // The labeling UI re-fetches after every assignment, so an unstable
1715        // order means the cluster lined up next moves, and so does the person
1716        // being dragged onto. Both lists were collected straight out of a
1717        // HashMap, which discarded the ORDER BY in the queries above.
1718        // `singletons` never had the bug, and the only difference is that it is
1719        // built as a Vec.
1720        let conn = seed();
1721        // The seed has one cluster and one person, which cannot show an
1722        // ordering problem. Add enough of both to have an order at all, with
1723        // sizes deliberately not matching id order.
1724        conn.execute_batch(
1725            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
1726            // so the table has more than the two it was created with.
1727            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
1728                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
1729             INSERT INTO people (name, full_name) VALUES ('bob','Bob');
1730             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1731                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
1732                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
1733                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
1734                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
1735                (10,'h10','0,0,9,9',X'0000',NULL,'bob',1,0);",
1736        )
1737        .unwrap();
1738
1739        // Two calls on one connection: each builds fresh HashMaps, and Rust
1740        // seeds them differently, so an unstable order shows up here.
1741        let a = faces_list(&conn).unwrap();
1742        let b = faces_list(&conn).unwrap();
1743
1744        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
1745        let names =
1746            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
1747        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
1748        assert_eq!(
1749            ids(&a),
1750            ids(&b),
1751            "cluster order must not change between calls"
1752        );
1753        assert_eq!(
1754            names(&a),
1755            names(&b),
1756            "people order must not change between calls"
1757        );
1758
1759        // And the order is the useful one: biggest first, so the cluster worth
1760        // the most labelling effort is where it is expected.
1761        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
1762        let mut want = sizes.clone();
1763        want.sort_unstable_by(|x, y| y.cmp(x));
1764        assert_eq!(
1765            sizes, want,
1766            "clusters must be ordered largest first, got {sizes:?}"
1767        );
1768    }
1769
1770    #[test]
1771    fn faces_list_splits_people_clusters_singletons() {
1772        let conn = seed();
1773        let d = faces_list(&conn).unwrap();
1774        assert_eq!(d.people.len(), 1);
1775        // Identity is the normalized form; what a reader sees is separate.
1776        assert_eq!(d.people[0].label, "alice");
1777        assert_eq!(d.people[0].full_name, "Alice");
1778        assert_eq!(
1779            d.people[0].representative_id, 1,
1780            "primary face is representative"
1781        );
1782        assert_eq!(d.clusters.len(), 1);
1783        assert_eq!(d.clusters[0].cluster_id, 7);
1784        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
1785        assert_eq!(d.singletons.len(), 1);
1786        assert_eq!(d.singletons[0].face_id, 5);
1787    }
1788
1789    #[test]
1790    fn person_detail_marks_primary() {
1791        let conn = seed();
1792        let p = person_detail(&conn, "Alice").unwrap();
1793        assert_eq!(p.faces.len(), 2);
1794        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
1795        assert!(!p.faces[1].is_primary);
1796    }
1797
1798    #[test]
1799    fn cluster_detail_lists_faces() {
1800        let conn = seed();
1801        let c = cluster_detail(&conn, 7).unwrap();
1802        assert_eq!(c.cluster_id, 7);
1803        assert_eq!(
1804            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
1805            vec![3, 4]
1806        );
1807    }
1808
1809    #[test]
1810    fn assign_labels_and_confirms() {
1811        let conn = seed();
1812        assign(&conn, &[3, 4], "Bob").unwrap();
1813        let p = person_detail(&conn, "Bob").unwrap();
1814        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
1815    }
1816
1817    #[test]
1818    fn assign_rejects_empty_label() {
1819        let conn = seed();
1820        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
1821    }
1822
1823    #[test]
1824    fn remove_face_unassigns_everything() {
1825        let conn = seed();
1826        remove_face(&conn, 1).unwrap();
1827        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
1828            .query_row(
1829                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
1830                [],
1831                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
1832            )
1833            .unwrap();
1834        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
1835    }
1836
1837    #[test]
1838    fn dissolve_cluster_nulls_cluster_id() {
1839        let conn = seed();
1840        dissolve_cluster(&conn, 7).unwrap();
1841        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
1842        assert_eq!(
1843            faces_list(&conn).unwrap().singletons.len(),
1844            3,
1845            "3,4 join 5 as singletons"
1846        );
1847    }
1848
1849    #[test]
1850    fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
1851        // A delete that matched zero faces is a no-op by design; it must not
1852        // schedule a whole-library regroup (watermark reset) for nothing.
1853        let conn = seed();
1854        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1855        let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
1856        assert!(before > 0);
1857        delete_person(&conn, "ghost").unwrap();
1858        assert_eq!(
1859            videre_core::face_db::recluster_watermark(&conn).unwrap(),
1860            before,
1861            "a no-op delete must not reopen the gated regroup"
1862        );
1863    }
1864
1865    #[test]
1866    fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
1867        // The real workflow: assign through the public path (which detaches
1868        // the cluster, per the frozen-faces contract), then delete the
1869        // person. The faces go back to the unassigned pool, and the recluster
1870        // watermark resets so the next gated pass regroups them: without the
1871        // reset, these pre-existing face ids sit below the watermark and the
1872        // gate stays closed forever.
1873        let conn = seed();
1874        assign(&conn, &[1, 2], "Alice").unwrap();
1875        assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
1876        // Simulate a completed recluster covering these faces: the watermark
1877        // sits at their ids, so the gate would stay closed for them forever.
1878        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1879        assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
1880
1881        delete_person(&conn, "Alice").unwrap();
1882        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
1883        assert_eq!(
1884            videre_core::face_db::recluster_watermark(&conn).unwrap(),
1885            0,
1886            "deleting a person must reopen the gated regroup for their faces"
1887        );
1888        let rows: Vec<(Option<i64>, Option<String>, i64)> = {
1889            let mut s = conn
1890                .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
1891                .unwrap();
1892            s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1893                .unwrap()
1894                .collect::<rusqlite::Result<_>>()
1895                .unwrap()
1896        };
1897        assert!(
1898            rows.iter()
1899                .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
1900            "every face returns to the unassigned pool: {rows:?}"
1901        );
1902    }
1903
1904    #[test]
1905    fn set_primary_is_exclusive_per_person() {
1906        let conn = seed();
1907        set_primary(&conn, 2, "Alice").unwrap();
1908        let primaries: Vec<i64> = {
1909            let mut s = conn
1910                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
1911                .unwrap();
1912            s.query_map([], |r| r.get(0))
1913                .unwrap()
1914                .collect::<rusqlite::Result<_>>()
1915                .unwrap()
1916        };
1917        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
1918    }
1919
1920    #[test]
1921    fn renaming_only_the_spelling_keeps_the_identity() {
1922        // The common rename: correcting or extending what is shown, which must
1923        // not change the URL or touch a single face row.
1924        let conn = seed();
1925        set_full_name(&conn, "alice", "Alice Smith").unwrap();
1926        let (name, full): (String, String) = conn
1927            .query_row("SELECT name, full_name FROM people", [], |r| {
1928                Ok((r.get(0)?, r.get(1)?))
1929            })
1930            .unwrap();
1931        assert_eq!(name, "alice", "identity is unchanged");
1932        assert_eq!(full, "Alice Smith", "only the display name moved");
1933        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
1934    }
1935
1936    // A write against a client-supplied id that matches no row is `Ok(0)` from
1937    // rusqlite, not an error. Reported as success it tells the labeling UI an
1938    // action worked when nothing changed. Each handler that takes an id from the
1939    // client must turn "matched nothing" into NotFound, the way set_full_name
1940    // already does.
1941
1942    #[test]
1943    fn assign_a_missing_face_is_not_found() {
1944        let conn = seed();
1945        assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
1946    }
1947
1948    #[test]
1949    fn assign_is_atomic_when_one_face_is_missing() {
1950        // face 3 exists, 999 does not. All-or-nothing: face 3 must be untouched
1951        // and no `Bob` person may be created, so a partial write can never be
1952        // reported as success.
1953        let conn = seed();
1954        assert!(matches!(
1955            assign(&conn, &[3, 999], "Bob"),
1956            Err(Error::NotFound)
1957        ));
1958        let (label, confirmed): (Option<String>, i64) = conn
1959            .query_row(
1960                "SELECT person_label, confirmed FROM faces WHERE id = 3",
1961                [],
1962                |r| Ok((r.get(0)?, r.get(1)?)),
1963            )
1964            .unwrap();
1965        assert_eq!(label, None, "face 3 must not have been labelled");
1966        assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
1967        let bob: i64 = conn
1968            .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
1969                r.get(0)
1970            })
1971            .unwrap();
1972        assert_eq!(
1973            bob, 0,
1974            "no person may be created when the assign rolls back"
1975        );
1976    }
1977
1978    #[test]
1979    fn assign_commit_failure_rolls_back_and_closes_the_transaction() {
1980        let conn = seed();
1981        conn.execute_batch(
1982            "PRAGMA foreign_keys = ON;
1983             CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
1984             CREATE TABLE commit_guard_child (
1985                 face_id INTEGER PRIMARY KEY,
1986                 parent_id INTEGER NOT NULL,
1987                 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
1988                     DEFERRABLE INITIALLY DEFERRED
1989             );
1990             CREATE TRIGGER fail_assign_commit
1991             AFTER UPDATE ON faces
1992             WHEN NEW.id = 3
1993             BEGIN
1994                 INSERT INTO commit_guard_child (face_id, parent_id)
1995                 VALUES (NEW.id, 999);
1996             END;",
1997        )
1998        .unwrap();
1999
2000        assert!(assign(&conn, &[3], "Bob").is_err());
2001        assert!(conn.is_autocommit());
2002        let state: (Option<String>, i64) = conn
2003            .query_row(
2004                "SELECT person_label, confirmed FROM faces WHERE id = 3",
2005                [],
2006                |row| Ok((row.get(0)?, row.get(1)?)),
2007            )
2008            .unwrap();
2009        assert_eq!(state, (None, 0));
2010        let bob: i64 = conn
2011            .query_row(
2012                "SELECT COUNT(*) FROM people WHERE name = 'bob'",
2013                [],
2014                |row| row.get(0),
2015            )
2016            .unwrap();
2017        assert_eq!(bob, 0);
2018    }
2019
2020    #[test]
2021    fn assign_rejects_empty_face_ids() {
2022        // Nothing to assign is a malformed request, not a silent success that
2023        // creates a person with no faces.
2024        let conn = seed();
2025        assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
2026    }
2027
2028    #[test]
2029    fn remove_face_missing_is_not_found() {
2030        let conn = seed();
2031        assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
2032    }
2033
2034    #[test]
2035    fn dissolve_cluster_missing_is_not_found() {
2036        let conn = seed();
2037        assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
2038    }
2039
2040    #[test]
2041    fn set_primary_missing_face_is_not_found() {
2042        let conn = seed();
2043        assert!(matches!(
2044            set_primary(&conn, 999, "Alice"),
2045            Err(Error::NotFound)
2046        ));
2047    }
2048
2049    #[test]
2050    fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
2051        // face 5 is an unassigned singleton, so the guarded update matches no
2052        // row for Alice. The failure must roll back the primary-clearing step:
2053        // Alice's existing primary (face 1) has to survive.
2054        let conn = seed();
2055        assert!(matches!(
2056            set_primary(&conn, 5, "Alice"),
2057            Err(Error::NotFound)
2058        ));
2059        let primary: i64 = conn
2060            .query_row(
2061                "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
2062                [],
2063                |r| r.get(0),
2064            )
2065            .unwrap();
2066        assert_eq!(
2067            primary, 1,
2068            "the original primary must be restored on rollback"
2069        );
2070    }
2071
2072    #[test]
2073    fn delete_person_missing_is_idempotent_success() {
2074        // Delete is idempotent: asking to unassign a person who is already gone
2075        // has already achieved its goal. A person can also legitimately have a
2076        // `people` row and no confirmed faces, which would make a row-count
2077        // check wrongly 404 a real person, so delete stays out of the NotFound
2078        // rule by design.
2079        let conn = seed();
2080        assert!(delete_person(&conn, "Nobody").is_ok());
2081    }
2082}
2083
2084#[cfg(test)]
2085mod identity_tests {
2086    use super::tests::seed;
2087    use super::*;
2088
2089    fn people(conn: &Connection) -> Vec<(String, String)> {
2090        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
2091            .unwrap()
2092            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2093            .unwrap()
2094            .collect::<rusqlite::Result<_>>()
2095            .unwrap()
2096    }
2097
2098    #[test]
2099    fn assign_stores_the_identity_and_records_the_display_name() {
2100        let conn = seed();
2101        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
2102
2103        let label: String = conn
2104            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
2105                r.get(0)
2106            })
2107            .unwrap();
2108        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
2109        assert!(
2110            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
2111            "and the spelling is kept for display"
2112        );
2113    }
2114
2115    #[test]
2116    fn assigning_an_existing_name_in_another_case_joins_that_person() {
2117        // The bug this whole change exists to fix: this used to create a second
2118        // person.
2119        let conn = seed();
2120        assign(&conn, &[3], "ALICE").unwrap();
2121        assert_eq!(people(&conn).len(), 1, "still one person, not two");
2122        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
2123        assert_eq!(
2124            people(&conn)[0].1,
2125            "Alice",
2126            "the existing spelling is not overwritten by the new casing"
2127        );
2128    }
2129
2130    #[test]
2131    fn assign_rejects_a_name_with_no_usable_identity() {
2132        // Punctuation alone leaves nothing to identify a person by, and an
2133        // empty identity would be a person nobody could address.
2134        let conn = seed();
2135        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
2136    }
2137
2138    #[test]
2139    fn person_detail_resolves_every_form_of_the_name() {
2140        let conn = seed();
2141        for form in ["alice", "Alice", "ALICE", "  alice  "] {
2142            assert_eq!(
2143                person_detail(&conn, form).unwrap().faces.len(),
2144                2,
2145                "form {form:?}"
2146            );
2147        }
2148    }
2149
2150    #[test]
2151    fn person_detail_reports_the_display_name() {
2152        let d = person_detail(&seed(), "alice").unwrap();
2153        assert_eq!(d.label, "alice");
2154        assert_eq!(d.full_name, "Alice");
2155    }
2156
2157    #[test]
2158    fn person_detail_falls_back_when_there_is_no_people_row() {
2159        // A label written before the table existed still has to render. The
2160        // orphan label is the pre-v2 shape, so its seed runs with enforcement
2161        // lifted and restored.
2162        let conn = seed();
2163        conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap();
2164        conn.execute(
2165            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
2166             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
2167            [],
2168        )
2169        .unwrap();
2170        conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
2171        let d = person_detail(&conn, "orphan").unwrap();
2172        assert_eq!(d.full_name, "orphan", "falls back to the identity");
2173    }
2174
2175    #[test]
2176    fn set_full_name_changes_only_the_display_name() {
2177        let conn = seed();
2178        set_full_name(&conn, "alice", "Alice Smith").unwrap();
2179        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
2180        assert_eq!(
2181            person_detail(&conn, "alice").unwrap().faces.len(),
2182            2,
2183            "no face was touched"
2184        );
2185    }
2186
2187    #[test]
2188    fn set_full_name_accepts_any_form_of_the_identity() {
2189        let conn = seed();
2190        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
2191        assert_eq!(people(&conn)[0].1, "Alice Smith");
2192    }
2193
2194    #[test]
2195    fn set_full_name_on_a_missing_person_is_not_found() {
2196        assert!(matches!(
2197            set_full_name(&seed(), "nobody", "Someone"),
2198            Err(Error::NotFound)
2199        ));
2200    }
2201
2202    #[test]
2203    fn set_full_name_rejects_an_empty_display_name() {
2204        // A person with no name to show is worse than one shown by identity.
2205        assert!(matches!(
2206            set_full_name(&seed(), "alice", "   "),
2207            Err(Error::Invalid)
2208        ));
2209    }
2210
2211    #[test]
2212    fn delete_person_accepts_any_form_of_the_name() {
2213        let conn = seed();
2214        delete_person(&conn, "Alice").unwrap();
2215        let left: i64 = conn
2216            .query_row(
2217                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
2218                [],
2219                |r| r.get(0),
2220            )
2221            .unwrap();
2222        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
2223    }
2224
2225    #[test]
2226    fn set_primary_accepts_any_form_of_the_name() {
2227        let conn = seed();
2228        set_primary(&conn, 2, "ALICE").unwrap();
2229        let primary: i64 = conn
2230            .query_row(
2231                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
2232                [],
2233                |r| r.get(0),
2234            )
2235            .unwrap();
2236        assert_eq!(primary, 2);
2237    }
2238}
2239
2240#[cfg(test)]
2241mod never_run_tests {
2242    use super::*;
2243
2244    /// :warning: **A scanned-but-never-detected library has no `faces` table.**
2245    ///
2246    /// `videre scan` creates `file_hashes`, `people` and `pipeline_runs`. The
2247    /// faces table arrives with the first `videre faces` run, so every query
2248    /// here failed with "no such table" until then. The server turned that into
2249    /// a 500 with an empty body, and the page turned the empty body into
2250    /// `Unexpected end of JSON input` across the top of the labeling UI.
2251    ///
2252    /// Every existing test in this file seeds a faces table, which is why none
2253    /// of them could see it: they all describe a library that has already run
2254    /// detection.
2255    #[test]
2256    fn a_library_that_never_ran_detection_is_empty_not_an_error() {
2257        let conn = Connection::open_in_memory().unwrap();
2258        conn.execute_batch(
2259            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
2260             CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
2261        )
2262        .unwrap();
2263
2264        let data = faces_list(&conn).expect("a library with no faces table is not an error");
2265        assert!(data.people.is_empty());
2266        assert!(data.clusters.is_empty());
2267        assert!(data.singletons.is_empty());
2268    }
2269
2270    // ---- questions ----
2271
2272    mod question_fixture {
2273        use super::*;
2274        use videre_core::face_learning::{
2275            ensure_question_tables, replace_pending_questions, select_questions, LogisticModel,
2276            LogisticScorer, ModelBundle, QuestionSelectionConfig, MEMBERSHIP_FEATURE_NAMES,
2277            MODEL_ARTIFACT_VERSION,
2278        };
2279
2280        pub fn embedding_blob(x: f32, y: f32) -> Vec<u8> {
2281            let mut bytes = Vec::with_capacity(4);
2282            bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
2283            bytes.extend_from_slice(&half::f16::from_f32(y).to_le_bytes());
2284            bytes
2285        }
2286
2287        fn logistic_bundle() -> ModelBundle {
2288            let names: Vec<String> = MEMBERSHIP_FEATURE_NAMES
2289                .iter()
2290                .map(|name| name.to_string())
2291                .collect();
2292            let means: Vec<f64> = names
2293                .iter()
2294                .map(|name| if name == "similarity_mean" { 1.0 } else { 0.0 })
2295                .collect();
2296            let scales: Vec<f64> = names
2297                .iter()
2298                .map(|name| if name == "similarity_mean" { 0.5 } else { 1.0 })
2299                .collect();
2300            let weights: Vec<f64> = names
2301                .iter()
2302                .map(|name| if name == "similarity_mean" { 2.0 } else { 0.0 })
2303                .collect();
2304            let scorer = LogisticScorer {
2305                model: LogisticModel {
2306                    feature_names: names,
2307                    means,
2308                    scales,
2309                    intercept: 0.0,
2310                    weights,
2311                    l2: 1.0,
2312                    positive_class_weight: 1.0,
2313                },
2314                calibration: videre_core::face_learning::CalibrationModel {
2315                    intercept: 0.0,
2316                    slope: 1.0,
2317                },
2318                threshold: 0.5,
2319            };
2320            ModelBundle::Logistic {
2321                artifact_version: MODEL_ARTIFACT_VERSION,
2322                embedding_model_id: "arcface/test".into(),
2323                feature_schema_version: 1,
2324                membership: scorer.clone(),
2325                cluster_quality: scorer,
2326            }
2327        }
2328
2329        /// Faces 10 and 11 sit in cluster 1; faces 12 and 13 confirm "alice".
2330        /// Returns the connection and the pending question id asking about
2331        /// cluster 1 and alice.
2332        /// Mirrors the planned foreign-key contract: enforcement on, and a face
2333        /// label must name an existing person.
2334        pub fn library() -> (Connection, i64, i64) {
2335            let conn = Connection::open_in_memory().unwrap();
2336            conn.execute_batch(
2337                "PRAGMA foreign_keys = ON;
2338                 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
2339                 CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
2340                 bbox TEXT NOT NULL, landmark TEXT, embedding BLOB NOT NULL,
2341                 cluster_id INTEGER,
2342                 person_label TEXT REFERENCES people(name) ON DELETE RESTRICT ON UPDATE RESTRICT,
2343                 confirmed INTEGER DEFAULT 0,
2344                 is_primary INTEGER DEFAULT 0, det_score REAL, blur REAL, oriented INTEGER);",
2345            )
2346            .unwrap();
2347            videre_core::face_learning::ensure_learning_tables(&conn).unwrap();
2348            videre_core::face_learning::ensure_profile_table(&conn).unwrap();
2349            ensure_question_tables(&conn).unwrap();
2350
2351            for (id, cluster) in [(10, Some(1)), (11, Some(1)), (12, None), (13, None)] {
2352                conn.execute(
2353                    "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2354                     VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, 0.9, 600.0)",
2355                    rusqlite::params![id, embedding_blob(1.0, 0.0), cluster],
2356                )
2357                .unwrap();
2358            }
2359            assign(&conn, &[12, 13], "Alice").unwrap();
2360
2361            let evidence =
2362                serde_json::to_string(&videre_core::face_learning::TrainingEvidenceCounts {
2363                    positive_pairs: 20,
2364                    negative_pairs: 20,
2365                    explicit_negative_pairs: 0,
2366                })
2367                .unwrap();
2368            let report = serde_json::to_string(&videre_core::face_learning::ValidationReport {
2369                protocol_version: 1,
2370                evidence_schema_version: 1,
2371                feature_schema_version: 1,
2372                datasets: Vec::new(),
2373            })
2374            .unwrap();
2375            conn.execute(
2376                "INSERT INTO face_learning_profiles (
2377                    artifact_version, embedding_model_id, feature_schema_version, model_kind,
2378                    parameters, training_evidence_json, validation_report_json, stage, status
2379                 ) VALUES (1, 'arcface/test', 1, 'logistic', ?1, ?2, ?3, 'suggestion', 'active')",
2380                rusqlite::params![
2381                    serde_json::to_vec(&logistic_bundle()).unwrap(),
2382                    evidence,
2383                    report
2384                ],
2385            )
2386            .unwrap();
2387            let profile_id = conn.last_insert_rowid();
2388
2389            let candidates = select_questions(&conn, &QuestionSelectionConfig::default()).unwrap();
2390            assert_eq!(candidates.len(), 1, "fixture must produce one question");
2391            let stored = replace_pending_questions(&conn, &candidates).unwrap();
2392            assert_eq!(stored.len(), 1);
2393            (conn, stored[0].id, profile_id)
2394        }
2395
2396        pub fn stub_evidence() -> videre_core::face_learning::DecisionEvidence {
2397            use videre_core::face_learning::{
2398                Calibration, DecisionKind, DecisionOutcome, DecisionTarget, FeatureContribution,
2399                ValidationSummary, EVIDENCE_SCHEMA_VERSION, FEATURE_SCHEMA_VERSION,
2400            };
2401            let evidence = videre_core::face_learning::DecisionEvidence {
2402                schema_version: EVIDENCE_SCHEMA_VERSION,
2403                profile_id: 1,
2404                feature_schema_version: FEATURE_SCHEMA_VERSION,
2405                decision_kind: DecisionKind::Membership,
2406                outcome: DecisionOutcome::Allowed,
2407                subject_face_ids: vec![10],
2408                target: DecisionTarget::Person("alice".into()),
2409                intercept: 0.0,
2410                raw_logit: 0.0,
2411                calibration: Calibration {
2412                    intercept: 0.0,
2413                    slope: 1.0,
2414                },
2415                calibrated_confidence: 0.5,
2416                threshold: 0.5,
2417                margin: 0.0,
2418                features: vec![FeatureContribution {
2419                    name: "similarity_mean".into(),
2420                    value: 1.0,
2421                    contribution: 0.0,
2422                }],
2423                support_face_ids: vec![12, 13],
2424                rule_vetoes: Vec::new(),
2425                validation: ValidationSummary {
2426                    protocol_version: 1,
2427                    datasets: 1,
2428                    pair_precision: None,
2429                    pair_recall: None,
2430                    suggestion_precision: None,
2431                    suggestion_coverage: None,
2432                },
2433            };
2434            evidence.validate().unwrap();
2435            evidence
2436        }
2437
2438        pub fn context(profile_id: i64) -> TeachingContext {
2439            TeachingContext {
2440                embedding_model_id: "arcface/test".into(),
2441                active_profile_id: Some(profile_id),
2442            }
2443        }
2444    }
2445
2446    use question_fixture as qf;
2447
2448    #[test]
2449    fn deleting_a_person_supersedes_questions_and_advances_once() {
2450        let (conn, _question_id, _profile_id) = qf::library();
2451        // A second pending question for the same identity: both must go.
2452        let second = videre_core::face_learning::StoredQuestion {
2453            id: 999,
2454            status: videre_core::face_learning::QuestionStatus::Pending,
2455            subject_face_ids: vec![10],
2456            support_face_ids: vec![12, 13],
2457            target_identity: "alice".into(),
2458            target_display: "Alice".into(),
2459            profile_id: 1,
2460            model_kind: "logistic".into(),
2461            representative_face_id: 10,
2462            cluster_id: 1,
2463            evidence_revision: "another-revision".into(),
2464            evidence: qf::stub_evidence(),
2465            created_at: "2026-01-01 00:00:00".into(),
2466            decided_at: None,
2467        };
2468        let _ = second;
2469        delete_person_with_learning(&conn, "Alice").unwrap();
2470        let superseded: i64 = conn
2471            .query_row(
2472                "SELECT count(*) FROM face_learning_questions WHERE status = 'superseded'",
2473                [],
2474                |row| row.get(0),
2475            )
2476            .unwrap();
2477        assert_eq!(superseded, 1, "the pending question must be superseded");
2478        let state = learning_state(&conn).unwrap();
2479        assert_eq!(state.generation, 1, "exactly one generation advance");
2480        let invalidated: i64 = conn
2481            .query_row(
2482                "SELECT count(*) FROM face_learning_events WHERE eligible = 0",
2483                [],
2484                |row| row.get(0),
2485            )
2486            .unwrap();
2487        assert_eq!(invalidated, 0, "no events existed to invalidate");
2488    }
2489
2490    #[test]
2491    fn the_journal_reports_availability_without_rewriting_history() {
2492        let (conn, _question_id, profile_id) = qf::library();
2493        // Produce journal entries, then remove a face an entry points at.
2494        assign_with_learning(&conn, &[10, 11], "Alice", &qf::context(profile_id)).unwrap();
2495        let subject_event_id = face_learning_events(&conn, 50, None, Some("arcface/test"))
2496            .unwrap()
2497            .iter()
2498            .find(|proof| proof.event.faces.iter().any(|face| face.face_id == 10))
2499            .map(|proof| proof.event.id)
2500            .unwrap();
2501        // A re-detection rebuild replaces face rows; simulate the row the
2502        // entry references vanishing.
2503        conn.execute("DELETE FROM faces WHERE id = 10", []).unwrap();
2504
2505        let proofs = face_learning_events(&conn, 50, None, Some("arcface/test")).unwrap();
2506        let proof = proofs
2507            .iter()
2508            .find(|proof| proof.event.id == subject_event_id)
2509            .unwrap();
2510        assert!(!proof.source_available, "the subject face is gone");
2511        assert!(!proof.incompatible, "same model and schema stay usable");
2512        assert!(proof.event.eligible, "missing provenance stays eligible");
2513
2514        // A different configured model marks the entry incompatible.
2515        let proofs = face_learning_events(&conn, 50, None, Some("other/model")).unwrap();
2516        let proof = proofs
2517            .iter()
2518            .find(|proof| proof.event.id == subject_event_id)
2519            .unwrap();
2520        assert!(proof.incompatible);
2521
2522        // Invalidation changes eligibility columns only, never the features.
2523        let (conn, question_id, profile_id) = qf::library();
2524        answer_question_with_learning(
2525            &conn,
2526            question_id,
2527            videre_core::face_learning::QuestionAnswer::No,
2528            &qf::context(profile_id),
2529        )
2530        .unwrap();
2531        let before: String = conn
2532            .query_row(
2533                "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2534                [],
2535                |row| row.get(0),
2536            )
2537            .unwrap();
2538        delete_person_with_learning(&conn, "Alice").unwrap();
2539        let after: String = conn
2540            .query_row(
2541                "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2542                [],
2543                |row| row.get(0),
2544            )
2545            .unwrap();
2546        assert_eq!(before, after, "historical feature JSON never mutates");
2547    }
2548
2549    #[test]
2550    fn yes_confirms_the_target_and_teaches_positive_membership() {
2551        let (conn, question_id, profile_id) = qf::library();
2552        let outcome = answer_question_with_learning(
2553            &conn,
2554            question_id,
2555            QuestionAnswer::Yes,
2556            &qf::context(profile_id),
2557        )
2558        .unwrap();
2559        assert_eq!(outcome.status, "answered");
2560        let ack = outcome.acknowledgement.expect("yes must teach");
2561        assert_eq!(ack.event_ids.len(), 1);
2562        assert_eq!(ack.generation, 1);
2563
2564        let labeled: i64 = conn
2565            .query_row(
2566                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND person_label = 'alice'
2567                 AND confirmed = 1 AND cluster_id IS NULL",
2568                [],
2569                |row| row.get(0),
2570            )
2571            .unwrap();
2572        assert_eq!(labeled, 2, "yes labels the whole subject cluster");
2573
2574        let event: (String, String, String) = conn
2575            .query_row(
2576                "SELECT action_kind, outcome, target_identity FROM face_learning_events",
2577                [],
2578                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2579            )
2580            .unwrap();
2581        assert_eq!(event.0, "question_yes");
2582        assert_eq!(event.1, "positive");
2583        assert_eq!(event.2, "alice");
2584    }
2585
2586    #[test]
2587    fn no_teaches_negative_without_labeling() {
2588        let (conn, question_id, profile_id) = qf::library();
2589        let outcome = answer_question_with_learning(
2590            &conn,
2591            question_id,
2592            QuestionAnswer::No,
2593            &qf::context(profile_id),
2594        )
2595        .unwrap();
2596        assert_eq!(outcome.status, "answered");
2597
2598        let untouched: i64 = conn
2599            .query_row(
2600                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 0
2601                 AND person_label IS NULL AND cluster_id = 1",
2602                [],
2603                |row| row.get(0),
2604            )
2605            .unwrap();
2606        assert_eq!(untouched, 2, "no must not label");
2607
2608        let event: (String, String) = conn
2609            .query_row(
2610                "SELECT action_kind, outcome FROM face_learning_events",
2611                [],
2612                |row| Ok((row.get(0)?, row.get(1)?)),
2613            )
2614            .unwrap();
2615        assert_eq!(event.0, "question_no");
2616        assert_eq!(event.1, "negative");
2617    }
2618
2619    #[test]
2620    fn skip_only_changes_delivery_state() {
2621        let (conn, question_id, profile_id) = qf::library();
2622        let outcome = answer_question_with_learning(
2623            &conn,
2624            question_id,
2625            QuestionAnswer::Skip,
2626            &qf::context(profile_id),
2627        )
2628        .unwrap();
2629        assert_eq!(outcome.status, "skipped");
2630        assert!(outcome.acknowledgement.is_none());
2631
2632        let events: i64 = conn
2633            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2634                row.get(0)
2635            })
2636            .unwrap();
2637        assert_eq!(events, 0, "skip produces no event");
2638        let state = learning_state(&conn).unwrap();
2639        assert_eq!(state.generation, 0, "skip does not advance generation");
2640    }
2641
2642    #[test]
2643    fn stale_answers_conflict_without_partial_writes() {
2644        // Already-labeled subject.
2645        let (conn, question_id, profile_id) = qf::library();
2646        assign(&conn, &[10, 11], "Bob").unwrap();
2647        assert!(matches!(
2648            answer_question_with_learning(
2649                &conn,
2650                question_id,
2651                QuestionAnswer::Yes,
2652                &qf::context(profile_id)
2653            ),
2654            Err(Error::Conflict)
2655        ));
2656        let events: i64 = conn
2657            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2658                row.get(0)
2659            })
2660            .unwrap();
2661        assert_eq!(events, 0, "a conflict must not teach");
2662        assert_eq!(
2663            videre_core::face_learning::stored_question(&conn, question_id)
2664                .unwrap()
2665                .unwrap()
2666                .status,
2667            QuestionStatus::Superseded
2668        );
2669
2670        // Removed target person. With face labels referencing people, the
2671        // row can only go once no face carries the label.
2672        let (conn, question_id, profile_id) = qf::library();
2673        conn.execute_batch(
2674            "UPDATE faces SET person_label = NULL, confirmed = 0 WHERE person_label = 'alice';
2675             DELETE FROM people WHERE name = 'alice';",
2676        )
2677        .unwrap();
2678        assert!(matches!(
2679            answer_question_with_learning(
2680                &conn,
2681                question_id,
2682                QuestionAnswer::No,
2683                &qf::context(profile_id)
2684            ),
2685            Err(Error::Conflict)
2686        ));
2687        assert_eq!(
2688            videre_core::face_learning::stored_question(&conn, question_id)
2689                .unwrap()
2690                .unwrap()
2691                .status,
2692            QuestionStatus::Superseded
2693        );
2694
2695        // A different active profile.
2696        let (conn, question_id, profile_id) = qf::library();
2697        conn.execute("UPDATE face_learning_profiles SET status = 'retired'", [])
2698            .unwrap();
2699        let _ = profile_id;
2700        assert!(matches!(
2701            answer_question_with_learning(&conn, question_id, QuestionAnswer::No, &qf::context(99)),
2702            Err(Error::Conflict)
2703        ));
2704        assert_eq!(
2705            videre_core::face_learning::stored_question(&conn, question_id)
2706                .unwrap()
2707                .unwrap()
2708                .status,
2709            QuestionStatus::Superseded
2710        );
2711
2712        // Support set changed: the evidence revision no longer matches.
2713        let (conn, question_id, profile_id) = qf::library();
2714        assign(&conn, &[13], "Alice").unwrap();
2715        remove_face(&conn, 12).unwrap();
2716        insert_face_with_score(&conn, 14, None, 0.9);
2717        assign(&conn, &[14], "Alice").unwrap();
2718        assert!(matches!(
2719            answer_question_with_learning(
2720                &conn,
2721                question_id,
2722                QuestionAnswer::No,
2723                &qf::context(profile_id)
2724            ),
2725            Err(Error::Conflict)
2726        ));
2727        let question = videre_core::face_learning::stored_question(&conn, question_id)
2728            .unwrap()
2729            .unwrap();
2730        assert_eq!(
2731            question.status,
2732            videre_core::face_learning::QuestionStatus::Superseded
2733        );
2734        assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2735    }
2736
2737    fn insert_face_with_score(conn: &Connection, id: i64, cluster: Option<i64>, score: f64) {
2738        conn.execute(
2739            "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2740             VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, ?4, 600.0)",
2741            rusqlite::params![id, qf::embedding_blob(1.0, 0.0), cluster, score],
2742        )
2743        .unwrap();
2744    }
2745
2746    #[test]
2747    fn faces_moved_out_of_the_question_cluster_conflict() {
2748        let (conn, question_id, profile_id) = qf::library();
2749        // A recluster reassigned one subject face after the question was
2750        // built: the evidence no longer describes the displayed cluster.
2751        conn.execute("UPDATE faces SET cluster_id = 9 WHERE id = 11", [])
2752            .unwrap();
2753        assert!(matches!(
2754            answer_question_with_learning(
2755                &conn,
2756                question_id,
2757                QuestionAnswer::Yes,
2758                &qf::context(profile_id)
2759            ),
2760            Err(Error::Conflict)
2761        ));
2762
2763        let labeled: i64 = conn
2764            .query_row(
2765                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
2766                [],
2767                |row| row.get(0),
2768            )
2769            .unwrap();
2770        assert_eq!(labeled, 0, "a stale cluster must not label");
2771        let events: i64 = conn
2772            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2773                row.get(0)
2774            })
2775            .unwrap();
2776        assert_eq!(events, 0);
2777        let question = videre_core::face_learning::stored_question(&conn, question_id)
2778            .unwrap()
2779            .unwrap();
2780        assert_eq!(
2781            question.status,
2782            videre_core::face_learning::QuestionStatus::Superseded
2783        );
2784        assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2785    }
2786
2787    #[test]
2788    fn refresh_creates_question_tables_for_a_first_training_cycle() {
2789        let (conn, _, _) = qf::library();
2790        conn.execute_batch(
2791            "DROP TABLE face_learning_question_faces;
2792             DROP TABLE face_learning_questions;",
2793        )
2794        .unwrap();
2795
2796        let questions = refresh_identity_questions(&conn, &QuestionSelectionConfig::default())
2797            .expect("a promoted profile should create the question tables");
2798        assert_eq!(questions.len(), 1);
2799        assert_eq!(pending_identity_questions(&conn, 5).unwrap().len(), 1);
2800    }
2801
2802    /// A real initialized version-2 library: foreign keys verified on, the
2803    /// canonical DDL in place. The public face paths must work unchanged and
2804    /// orphan writes must fail.
2805    #[test]
2806    fn v2_library_enforces_keys_through_the_public_paths() {
2807        use videre_core::face_learning::QuestionAnswer as Answer;
2808        let root = tempfile::tempdir().unwrap();
2809        let cache = tempfile::tempdir().unwrap();
2810        let ctx = videre_core::library::LibraryContext::new(root.path(), cache.path()).unwrap();
2811        let conn = videre_core::library_db::initialize(&ctx).unwrap();
2812        let keys_on: i64 = conn
2813            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
2814            .unwrap();
2815        assert_eq!(keys_on, 1, "an initialized library verifies enforcement");
2816
2817        // Seed two people with one confirmed face each, through the public
2818        // assign path, after detectable faces exist as unassigned clusters.
2819        conn.execute_batch(
2820            "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur) VALUES
2821                (1, 'k1', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0),
2822                (2, 'k2', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0);
2823             INSERT INTO people (name, full_name) VALUES ('alice', 'Alice'), ('bob', 'Bob');",
2824        )
2825        .unwrap();
2826        assign(&conn, &[1], "Alice").unwrap();
2827        assign(&conn, &[2], "Bob").unwrap();
2828
2829        // Orphan writes fail: a face labeled with an unknown person, and an
2830        // event provenance row with no parent event.
2831        assert!(conn
2832            .execute(
2833                "INSERT INTO faces (hash,bbox,embedding,person_label,confirmed)
2834                 VALUES ('k9','0,0,9,9',X'0000','ghost',1)",
2835                [],
2836            )
2837            .is_err());
2838        assert!(conn
2839            .execute(
2840                "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2841                 VALUES (999, 1, 'subject', 0)",
2842                [],
2843            )
2844            .is_err());
2845
2846        // Parent-first inserts succeed.
2847        conn.execute(
2848            "INSERT INTO face_learning_events (id, action_kind, decision_kind, outcome,
2849                embedding_model_id, feature_schema_version, target_identity,
2850                feature_snapshot_json, support_count)
2851             VALUES (1, 'assign_face', 'membership', 'positive', 'x/1', 1, 'alice', '{}', 0)",
2852            [],
2853        )
2854        .unwrap();
2855        conn.execute(
2856            "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2857             VALUES (1, 1, 'subject', 0)",
2858            [],
2859        )
2860        .unwrap();
2861
2862        // Deleting a person invalidates their evidence; the face the user
2863        // assigned becomes unassigned again.
2864        delete_person_with_learning(&conn, "Alice").unwrap();
2865        let state: (i64, Option<String>) = conn
2866            .query_row(
2867                "SELECT confirmed, person_label FROM faces WHERE id = 1",
2868                [],
2869                |r| Ok((r.get(0)?, r.get(1)?)),
2870            )
2871            .unwrap();
2872        assert_eq!(state, (0, None));
2873
2874        // A stale question is rejected by the answer path.
2875        let question = videre_core::face_learning::select_questions(
2876            &conn,
2877            &videre_core::face_learning::QuestionSelectionConfig::default(),
2878        )
2879        .unwrap();
2880        if !question.is_empty() {
2881            let stored =
2882                videre_core::face_learning::replace_pending_questions(&conn, &question).unwrap();
2883            conn.execute(
2884                "UPDATE face_learning_questions SET evidence_revision = 'stale' WHERE id = ?1",
2885                rusqlite::params![stored[0].id],
2886            )
2887            .unwrap();
2888            let context = TeachingContext {
2889                embedding_model_id: "x/1".into(),
2890                active_profile_id: None,
2891            };
2892            assert!(matches!(
2893                answer_question_with_learning(&conn, stored[0].id, Answer::Yes, &context),
2894                Err(Error::Conflict)
2895            ));
2896        }
2897
2898        // Reset clears every learning table and passes foreign_key_check.
2899        videre_core::face_db::reset_all(&conn).unwrap();
2900        for table in [
2901            "face_learning_events",
2902            "face_learning_event_faces",
2903            "face_learning_questions",
2904            "face_learning_question_faces",
2905            "face_learning_profiles",
2906        ] {
2907            let n: i64 = conn
2908                .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
2909                .unwrap();
2910            assert_eq!(n, 0, "{table} must be empty after reset");
2911        }
2912        let violations: i64 = conn
2913            .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
2914                r.get(0)
2915            })
2916            .unwrap();
2917        assert_eq!(violations, 0);
2918    }
2919
2920    #[test]
2921    fn yes_cannot_label_without_evidence() {
2922        let (conn, question_id, profile_id) = qf::library();
2923        conn.execute_batch(
2924            "CREATE TRIGGER abort_question_events
2925             BEFORE INSERT ON face_learning_events
2926             BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;",
2927        )
2928        .unwrap();
2929        assert!(answer_question_with_learning(
2930            &conn,
2931            question_id,
2932            QuestionAnswer::Yes,
2933            &qf::context(profile_id)
2934        )
2935        .is_err());
2936
2937        let labeled: i64 = conn
2938            .query_row(
2939                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
2940                [],
2941                |row| row.get(0),
2942            )
2943            .unwrap();
2944        assert_eq!(labeled, 0, "yes cannot label without its evidence row");
2945
2946        let question = videre_core::face_learning::stored_question(&conn, question_id)
2947            .unwrap()
2948            .unwrap();
2949        assert_eq!(
2950            question.status,
2951            videre_core::face_learning::QuestionStatus::Pending
2952        );
2953    }
2954}