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    let waiting = state.status == videre_core::face_learning::LearningStatus::Waiting;
1067    let failed = state.status == videre_core::face_learning::LearningStatus::Failed;
1068    Ok(FaceLearningStatus {
1069        generation: state.generation,
1070        trained_generation: state.trained_generation,
1071        status: format!("{:?}", state.status).to_lowercase(),
1072        last_profile_id: state.last_profile_id,
1073        last_candidate,
1074        // Each reported only in the state it describes: a path that moves the
1075        // state on without clearing a column never shows a stale message.
1076        last_error: state.last_error.filter(|_| failed),
1077        feedback_needed: state.feedback_needed.filter(|_| waiting),
1078        pending_questions: pending_questions as usize,
1079    })
1080}
1081
1082/// One journal entry plus read-time proof facts. `source_available` says
1083/// whether every referenced face still exists; `incompatible` says whether
1084/// the entry can no longer feed training. Both are computed at read time and
1085/// never rewrite the historical row.
1086#[derive(Debug, Clone, serde::Serialize)]
1087pub struct FaceLearningEventProof {
1088    #[serde(flatten)]
1089    pub event: videre_core::face_learning::StoredLearningEvent,
1090    pub source_available: bool,
1091    pub incompatible: bool,
1092}
1093
1094fn proof_for(
1095    conn: &Connection,
1096    event: videre_core::face_learning::StoredLearningEvent,
1097    current_embedding_model_id: Option<&str>,
1098) -> Result<FaceLearningEventProof> {
1099    let mut source_available = true;
1100    for face in &event.faces {
1101        let exists: bool = conn.query_row(
1102            "SELECT EXISTS(SELECT 1 FROM faces WHERE id = ?1)",
1103            [face.face_id],
1104            |row| row.get(0),
1105        )?;
1106        if !exists {
1107            source_available = false;
1108            break;
1109        }
1110    }
1111    let incompatible = event.features.schema_version
1112        != videre_core::face_learning::FEATURE_SCHEMA_VERSION
1113        || current_embedding_model_id.is_some_and(|model| model != event.embedding_model_id);
1114    Ok(FaceLearningEventProof {
1115        event,
1116        source_available,
1117        incompatible,
1118    })
1119}
1120
1121/// Learning events, newest first. Payloads carry scalar feature snapshots and
1122/// provenance ids only; embeddings never leave the library.
1123pub fn face_learning_events(
1124    conn: &Connection,
1125    limit: usize,
1126    before_id: Option<i64>,
1127    current_embedding_model_id: Option<&str>,
1128) -> Result<Vec<FaceLearningEventProof>> {
1129    videre_core::face_learning::ensure_learning_tables(conn)?;
1130    let limit = limit.clamp(1, 200);
1131    let events = list_learning_events(conn, limit, before_id)?;
1132    events
1133        .into_iter()
1134        .map(|event| proof_for(conn, event, current_embedding_model_id))
1135        .collect()
1136}
1137
1138pub fn face_learning_event(
1139    conn: &Connection,
1140    event_id: i64,
1141    current_embedding_model_id: Option<&str>,
1142) -> Result<Option<FaceLearningEventProof>> {
1143    videre_core::face_learning::ensure_learning_tables(conn)?;
1144    match videre_core::face_learning::learning_event(conn, event_id)? {
1145        Some(event) => Ok(Some(proof_for(conn, event, current_embedding_model_id)?)),
1146        None => Ok(None),
1147    }
1148}
1149
1150/// Load the immutable training inputs for the learning worker's snapshot.
1151pub fn load_training_snapshot(
1152    conn: &Connection,
1153    embedding_model_id: &str,
1154    generation: u64,
1155    config: &videre_core::face_learning::TrainingConfig,
1156) -> std::result::Result<videre_core::face_learning::TrainingSnapshot, String> {
1157    let labels =
1158        videre_core::face_db::load_confirmed_face_labels(conn).map_err(|e| e.to_string())?;
1159    let face_ids: Vec<i64> = {
1160        let mut statement = conn
1161            .prepare("SELECT id FROM faces ORDER BY id")
1162            .map_err(|e| e.to_string())?;
1163        let rows = statement
1164            .query_map([], |row| row.get(0))
1165            .map_err(|e| e.to_string())?
1166            .collect::<rusqlite::Result<Vec<i64>>>()
1167            .map_err(|e| e.to_string())?;
1168        rows
1169    };
1170    let observations =
1171        videre_core::face_db::load_face_observations(conn, &face_ids).map_err(|e| e.to_string())?;
1172    let events = videre_core::face_learning::eligible_events_for_training(
1173        conn,
1174        embedding_model_id,
1175        videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1176    )
1177    .map_err(|e| e.to_string())?;
1178    videre_core::face_learning::build_training_snapshot(
1179        generation,
1180        embedding_model_id,
1181        &labels,
1182        &observations,
1183        &events,
1184        config,
1185    )
1186    .map_err(|e| e.to_string())
1187}
1188
1189/// Persist a trained candidate: insert, promote through the shipped gates,
1190/// and return the profile identity and verdict. The profile row keeps the
1191/// promotion outcome either way.
1192pub fn persist_trained_profile(
1193    conn: &Connection,
1194    embedding_model_id: &str,
1195    run: &videre_core::face_learning::TrainingRun,
1196    gates: &videre_core::face_learning::PromotionGates,
1197) -> Result<TrainedProfileSummary> {
1198    let validation = match run.comparison.selected {
1199        videre_core::face_learning::CandidateKind::Logistic => &run.logistic_validation,
1200        videre_core::face_learning::CandidateKind::Additive => &run.additive_validation,
1201    };
1202    let profile = videre_core::face_learning::NewProfile {
1203        artifact_version: videre_core::face_learning::PROFILE_ARTIFACT_VERSION,
1204        embedding_model_id: embedding_model_id.to_owned(),
1205        feature_schema_version: videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1206        model_kind: run.selected.model_kind().to_owned(),
1207        parameters: serde_json::to_vec(&run.selected).map_err(Error::from)?,
1208        training_evidence: run.evidence_counts.clone(),
1209        validation_report: validation.clone(),
1210        stage: videre_core::face_learning::ProfileStage::Suggestion,
1211    };
1212    let profile_id = videre_core::face_learning::insert_candidate(conn, &profile)?;
1213    let outcome = videre_core::face_learning::evaluate_and_promote(conn, profile_id, gates)?;
1214    Ok(TrainedProfileSummary {
1215        profile_id,
1216        model_kind: profile.model_kind,
1217        promoted: outcome == videre_core::face_learning::PromotionOutcome::Promoted,
1218    })
1219}
1220
1221/// Mark one face as the person's primary (their labeling-page thumbnail),
1222/// clearing any previous primary in the same transaction so exactly one
1223/// remains. The target update is guarded by person_label so it can't steal a
1224/// face from another person.
1225pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
1226    let person_label =
1227        videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
1228    conn.execute_batch("BEGIN")?;
1229    let result = (|| -> Result<()> {
1230        conn.execute(
1231            "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
1232            rusqlite::params![person_label],
1233        )?;
1234        // The guard on person_label means a face id that does not exist, or
1235        // belongs to someone else, matches no row: `Ok(0)`, not an error. That
1236        // is a NotFound, and the rollback restores the primary cleared above so
1237        // a failed call leaves the person's primary untouched.
1238        let n = conn.execute(
1239            "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
1240            rusqlite::params![person_label, face_id],
1241        )?;
1242        if n == 0 {
1243            return Err(Error::NotFound);
1244        }
1245        Ok(())
1246    })();
1247    match result {
1248        Ok(()) => {
1249            conn.execute_batch("COMMIT")?;
1250            Ok(())
1251        }
1252        Err(e) => {
1253            let _ = conn.execute_batch("ROLLBACK");
1254            Err(e)
1255        }
1256    }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261    use super::*;
1262
1263    #[test]
1264    fn assign_detaches_the_face_from_its_cluster() {
1265        let conn = seed();
1266        // Face 3 sits in cluster 7 (unassigned). Assigning it to a person
1267        // must detach the machine grouping in the same write.
1268        assign(&conn, &[3], "Bob").unwrap();
1269        let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
1270            .query_row(
1271                "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
1272                [],
1273                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1274            )
1275            .unwrap();
1276        assert_eq!(label.as_deref(), Some("bob"));
1277        assert_eq!(confirmed, 1);
1278        assert_eq!(cid, None, "assignment must detach the machine grouping");
1279    }
1280
1281    #[test]
1282    fn cluster_detail_never_shows_labeled_faces() {
1283        let conn = seed();
1284        // A tombstone the detach migration should have cleared: a labeled
1285        // face still carrying a cluster id. The filter keeps the detail
1286        // page from ever showing it, whatever wrote that row.
1287        conn.execute(
1288            "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
1289                (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
1290            [],
1291        )
1292        .unwrap();
1293        conn.execute(
1294            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
1295            [],
1296        )
1297        .unwrap();
1298        let detail = cluster_detail(&conn, 7).unwrap();
1299        assert_eq!(
1300            detail.faces.len(),
1301            2,
1302            "only the unlabeled faces of cluster 7 belong on the page"
1303        );
1304    }
1305
1306    /// In-memory db with the faces + file_hashes tables and a few rows:
1307    /// - face 1: person "Alice", confirmed, is_primary
1308    /// - face 2: person "Alice", confirmed
1309    /// - face 3: cluster 7 (unassigned)
1310    /// - face 4: cluster 7 (unassigned)
1311    /// - face 5: singleton (no cluster, unassigned)
1312    pub(super) fn seed() -> Connection {
1313        let conn = Connection::open_in_memory().unwrap();
1314        videre_core::face_db::create_faces_table(&conn).unwrap();
1315        conn.execute_batch(
1316            "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1317             INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
1318                ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
1319             -- Labels are stored in identity form, as `assign` writes them and
1320             -- as the migration leaves them; `people` carries what a reader
1321             -- sees. Seeding raw 'Alice' would test a state the application no
1322             -- longer produces.
1323             INSERT INTO people (name, full_name) VALUES ('alice','Alice');
1324             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1325                (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
1326                (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
1327                (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
1328                (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
1329                (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
1330        )
1331        .unwrap();
1332        videre_core::db::ensure_file_hashes_columns(&conn);
1333        conn
1334    }
1335
1336    mod learning {
1337        use super::*;
1338        use videre_core::face_learning::{
1339            learning_state, list_learning_events, LearningAction, LearningDecisionKind,
1340            LearningOutcome,
1341        };
1342
1343        fn context() -> TeachingContext {
1344            TeachingContext {
1345                embedding_model_id: "buffalo_l/w600k_r50.onnx".to_owned(),
1346                active_profile_id: None,
1347            }
1348        }
1349
1350        fn embedding(x: u16, y: u16) -> Vec<u8> {
1351            [x.to_le_bytes(), y.to_le_bytes()].concat()
1352        }
1353
1354        fn learning_seed() -> Connection {
1355            let conn = Connection::open_in_memory().unwrap();
1356            videre_core::face_db::create_faces_table(&conn).unwrap();
1357            conn.execute_batch(
1358                "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1359                 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice');",
1360            )
1361            .unwrap();
1362            let rows = [
1363                (1, "a1", embedding(0x3c00, 0), None, Some("alice"), 1),
1364                (2, "a2", embedding(0x3b9a, 0x3266), None, Some("alice"), 1),
1365                (3, "c1", embedding(0x3c00, 0), Some(7), None, 0),
1366                (4, "c2", embedding(0x3b9a, 0x3266), Some(7), None, 0),
1367                (5, "c3", embedding(0x3b33, 0x34cd), Some(7), None, 0),
1368                (6, "s1", embedding(0x3266, 0x3b9a), None, None, 0),
1369                (7, "d1", embedding(0x3c00, 0), Some(9), None, 0),
1370                (8, "d2", embedding(0, 0x3c00), Some(9), None, 0),
1371            ];
1372            for (id, hash, bytes, cluster, label, confirmed) in rows {
1373                conn.execute(
1374                    "INSERT INTO file_hashes (hash, path) VALUES (?1, ?2)",
1375                    rusqlite::params![hash, format!("/p/{hash}.jpg")],
1376                )
1377                .unwrap();
1378                conn.execute(
1379                    "INSERT INTO faces
1380                     (id, hash, bbox, embedding, cluster_id, person_label, confirmed,
1381                      is_primary, det_score, blur)
1382                     VALUES (?1, ?2, '0,0,112,112', ?3, ?4, ?5, ?6, 0, 0.95, 900.0)",
1383                    rusqlite::params![id, hash, bytes, cluster, label, confirmed],
1384                )
1385                .unwrap();
1386            }
1387            conn
1388        }
1389
1390        #[test]
1391        fn learning_assignments_emit_expected_positive_evidence_once_per_action() {
1392            let conn = learning_seed();
1393
1394            let assigned = assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1395            assert_eq!(assigned.generation, 1);
1396            assert_eq!(assigned.event_ids.len(), 1);
1397
1398            let labeled = new_person_with_learning(&conn, &[3, 4, 5], "Bob", &context()).unwrap();
1399            assert_eq!(labeled.generation, 2);
1400            assert_eq!(labeled.event_ids.len(), 4);
1401
1402            let events = list_learning_events(&conn, 20, None).unwrap();
1403            assert_eq!(events.len(), 5);
1404            assert_eq!(
1405                events
1406                    .iter()
1407                    .filter(|event| event.action == LearningAction::LabelCluster
1408                        && event.decision_kind == LearningDecisionKind::ClusterQuality
1409                        && event.outcome == LearningOutcome::Positive)
1410                    .count(),
1411                1
1412            );
1413            assert_eq!(
1414                events
1415                    .iter()
1416                    .filter(
1417                        |event| event.decision_kind == LearningDecisionKind::Membership
1418                            && event.outcome == LearningOutcome::Positive
1419                    )
1420                    .count(),
1421                4
1422            );
1423            assert!(events.iter().all(|event| {
1424                let json = event.features.to_canonical_json().unwrap();
1425                !json.contains("alice") && !json.contains("bob") && !json.contains("/p/")
1426            }));
1427
1428            let conn = learning_seed();
1429            let assigned_cluster =
1430                assign_with_learning(&conn, &[3, 4, 5], "alice", &context()).unwrap();
1431            assert_eq!(assigned_cluster.generation, 1);
1432            assert_eq!(assigned_cluster.event_ids.len(), 4);
1433            let events = list_learning_events(&conn, 20, None).unwrap();
1434            assert_eq!(
1435                events
1436                    .iter()
1437                    .filter(|event| event.action == LearningAction::AssignCluster
1438                        && event.decision_kind == LearningDecisionKind::Membership)
1439                    .count(),
1440                3
1441            );
1442            assert_eq!(
1443                events
1444                    .iter()
1445                    .filter(|event| event.action == LearningAction::AssignCluster
1446                        && event.decision_kind == LearningDecisionKind::ClusterQuality)
1447                    .count(),
1448                1
1449            );
1450        }
1451
1452        #[test]
1453        fn a_large_cluster_has_a_deterministic_per_action_membership_cap() {
1454            let conn = learning_seed();
1455            for id in 10..22 {
1456                let hash = format!("large-{id}");
1457                conn.execute(
1458                    "INSERT INTO faces
1459                     (id, hash, bbox, embedding, cluster_id, confirmed, is_primary,
1460                      det_score, blur)
1461                     VALUES (?1, ?2, '0,0,112,112', ?3, 42, 0, 0, 0.95, 900.0)",
1462                    rusqlite::params![id, hash, embedding(0x3c00, (id as u16) + 0x2000)],
1463                )
1464                .unwrap();
1465            }
1466            let ids: Vec<_> = (10..22).collect();
1467            let acknowledgement =
1468                new_person_with_learning(&conn, &ids, "Large Family", &context()).unwrap();
1469            assert_eq!(
1470                acknowledgement.event_ids.len(),
1471                1 + MAX_MEMBERSHIP_EVENTS_PER_ACTION
1472            );
1473            assert_eq!(learning_state(&conn).unwrap().generation, 1);
1474
1475            let events = list_learning_events(&conn, 20, None).unwrap();
1476            assert_eq!(
1477                events
1478                    .iter()
1479                    .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1480                    .count(),
1481                MAX_MEMBERSHIP_EVENTS_PER_ACTION
1482            );
1483            assert!(events
1484                .iter()
1485                .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1486                .all(|event| event.support_count as usize <= MAX_SUPPORT_FACES));
1487        }
1488
1489        #[test]
1490        fn learning_corrections_use_pre_action_state_without_pairwise_dissolve_labels() {
1491            let conn = learning_seed();
1492
1493            let removed_cluster = remove_face_with_learning(&conn, 3, &context()).unwrap();
1494            assert_eq!(removed_cluster.generation, 1);
1495            let removed_person = remove_face_with_learning(&conn, 2, &context()).unwrap();
1496            assert_eq!(removed_person.generation, 2);
1497            let dissolved = dissolve_cluster_with_learning(&conn, 9, &context()).unwrap();
1498            assert_eq!(dissolved.generation, 3);
1499
1500            let events = list_learning_events(&conn, 20, None).unwrap();
1501            assert_eq!(events.len(), 3);
1502            assert_eq!(
1503                events
1504                    .iter()
1505                    .filter(
1506                        |event| event.decision_kind == LearningDecisionKind::Membership
1507                            && event.outcome == LearningOutcome::Negative
1508                    )
1509                    .count(),
1510                2
1511            );
1512            let dissolve = events
1513                .iter()
1514                .find(|event| event.action == LearningAction::DissolveCluster)
1515                .unwrap();
1516            assert_eq!(dissolve.decision_kind, LearningDecisionKind::ClusterQuality);
1517            assert_eq!(dissolve.outcome, LearningOutcome::Negative);
1518            assert_eq!(dissolve.faces.len(), 2);
1519        }
1520
1521        #[test]
1522        fn unsupported_last_face_removals_still_apply_without_fabricated_evidence() {
1523            let conn = learning_seed();
1524            remove_face_with_learning(&conn, 1, &context()).unwrap();
1525            let last_person_face = remove_face_with_learning(&conn, 2, &context()).unwrap();
1526            assert!(last_person_face.event_ids.is_empty());
1527            assert_eq!(last_person_face.generation, 1);
1528            let person_state: (Option<String>, i64) = conn
1529                .query_row(
1530                    "SELECT person_label, confirmed FROM faces WHERE id = 2",
1531                    [],
1532                    |row| Ok((row.get(0)?, row.get(1)?)),
1533                )
1534                .unwrap();
1535            assert_eq!(person_state, (None, 0));
1536
1537            remove_face_with_learning(&conn, 3, &context()).unwrap();
1538            remove_face_with_learning(&conn, 4, &context()).unwrap();
1539            let last_cluster_face = remove_face_with_learning(&conn, 5, &context()).unwrap();
1540            assert!(last_cluster_face.event_ids.is_empty());
1541            assert_eq!(last_cluster_face.generation, 3);
1542            let cluster_id: Option<i64> = conn
1543                .query_row("SELECT cluster_id FROM faces WHERE id = 5", [], |row| {
1544                    row.get(0)
1545                })
1546                .unwrap();
1547            assert_eq!(cluster_id, None);
1548        }
1549
1550        /// The waiting ask says "from a group of two or more faces" because
1551        /// that is what trains again: a person named from one face records
1552        /// nothing and leaves the state where it was.
1553        #[test]
1554        fn following_the_waiting_ask_starts_a_new_run_and_one_face_does_not() {
1555            let conn = learning_seed();
1556            assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1557            videre_core::face_learning::mark_training_started(&conn).unwrap();
1558            let ask = videre_core::face_learning::TrainingError::OneSidedFold {
1559                decision_kind: videre_core::face_learning::LearningDecisionKind::Membership,
1560                lacking_negatives: true,
1561            }
1562            .feedback_needed(&videre_core::face_learning::TrainingConfig::default())
1563            .unwrap();
1564            assert_eq!(ask, "name 1 more person from a group of two or more faces");
1565            videre_core::face_learning::mark_training_waiting(&conn, 1, &ask).unwrap();
1566
1567            let single = new_person_with_learning(&conn, &[6], "Çağla", &context()).unwrap();
1568            assert!(single.event_ids.is_empty());
1569            let status = face_learning_status(&conn).unwrap();
1570            assert_eq!(
1571                (status.generation, status.status.as_str()),
1572                (1, "waiting"),
1573                "one face records nothing, so nothing new is trained"
1574            );
1575            assert_eq!(status.feedback_needed.as_deref(), Some(ask.as_str()));
1576
1577            let group = new_person_with_learning(&conn, &[3, 4, 5], "Özgür", &context()).unwrap();
1578            assert!(!group.event_ids.is_empty());
1579            let status = face_learning_status(&conn).unwrap();
1580            assert_eq!(
1581                (status.generation, status.status.as_str()),
1582                (2, "stale"),
1583                "a named group is new evidence, so the worker trains again"
1584            );
1585            assert_eq!(status.feedback_needed, None);
1586            assert_eq!(
1587                status.last_error, None,
1588                "the ask stored for the waiting run never reads as an error"
1589            );
1590        }
1591
1592        /// The ask shares the column a failure's error uses. A path that marks
1593        /// the state stale without clearing it (the foreign-key repair) must
1594        /// not turn the ask into a reported error.
1595        #[test]
1596        fn a_stale_state_never_reports_the_waiting_ask_as_an_error() {
1597            let conn = learning_seed();
1598            assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1599            videre_core::face_learning::mark_training_started(&conn).unwrap();
1600            videre_core::face_learning::mark_training_waiting(
1601                &conn,
1602                1,
1603                "dissolve 2 more wrong clusters",
1604            )
1605            .unwrap();
1606            conn.execute(
1607                "UPDATE face_learning_state SET generation = generation + 1, status = 'stale'",
1608                [],
1609            )
1610            .unwrap();
1611            let status = face_learning_status(&conn).unwrap();
1612            assert_eq!(status.status, "stale");
1613            assert_eq!(status.last_error, None);
1614            assert_eq!(status.feedback_needed, None);
1615        }
1616
1617        #[test]
1618        fn new_person_collision_uses_existing_person_support() {
1619            let conn = learning_seed();
1620            let acknowledgement =
1621                new_person_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1622            assert_eq!(acknowledgement.generation, 1);
1623            assert_eq!(acknowledgement.event_ids.len(), 1);
1624            let events = list_learning_events(&conn, 10, None).unwrap();
1625            assert_eq!(events[0].action, LearningAction::AssignFace);
1626            assert_eq!(events[0].target_identity.as_deref(), Some("alice"));
1627            assert_eq!(events[0].support_count, 2);
1628        }
1629
1630        #[test]
1631        fn assigning_to_a_face_less_person_keeps_only_supported_evidence() {
1632            let conn = learning_seed();
1633            conn.execute(
1634                "UPDATE faces
1635                 SET person_label = NULL, confirmed = 0
1636                 WHERE person_label = 'alice'",
1637                [],
1638            )
1639            .unwrap();
1640
1641            let singleton = assign_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1642            assert!(singleton.event_ids.is_empty());
1643            assert_eq!(singleton.generation, 0);
1644            assert_eq!(singleton.message_key, "face_named_without_comparison");
1645            let assigned: (Option<String>, i64) = conn
1646                .query_row(
1647                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1648                    [],
1649                    |row| Ok((row.get(0)?, row.get(1)?)),
1650                )
1651                .unwrap();
1652            assert_eq!(assigned, (Some("alice".to_owned()), 1));
1653            assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1654
1655            let conn = learning_seed();
1656            conn.execute(
1657                "UPDATE faces
1658                 SET person_label = NULL, confirmed = 0
1659                 WHERE person_label = 'alice'",
1660                [],
1661            )
1662            .unwrap();
1663            let cluster = new_person_with_learning(&conn, &[3, 4, 5], "Alice", &context()).unwrap();
1664            assert_eq!(cluster.event_ids.len(), 1);
1665            assert_eq!(cluster.generation, 1);
1666            let events = list_learning_events(&conn, 10, None).unwrap();
1667            assert_eq!(events.len(), 1);
1668            assert_eq!(events[0].action, LearningAction::AssignCluster);
1669            assert_eq!(
1670                events[0].decision_kind,
1671                LearningDecisionKind::ClusterQuality
1672            );
1673        }
1674
1675        #[test]
1676        fn event_insert_failure_rolls_back_the_visible_assignment_and_generation() {
1677            let conn = learning_seed();
1678            conn.execute_batch(
1679                "CREATE TRIGGER reject_learning_event
1680                 BEFORE INSERT ON face_learning_events
1681                 BEGIN SELECT RAISE(ABORT, 'test rejection'); END;",
1682            )
1683            .unwrap();
1684
1685            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1686            let state: (Option<String>, i64) = conn
1687                .query_row(
1688                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1689                    [],
1690                    |row| Ok((row.get(0)?, row.get(1)?)),
1691                )
1692                .unwrap();
1693            assert_eq!(state, (None, 0));
1694            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1695            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1696        }
1697
1698        #[test]
1699        fn commit_failure_rolls_back_faces_events_and_generation() {
1700            let conn = learning_seed();
1701            conn.execute_batch(
1702                "PRAGMA foreign_keys = ON;
1703                 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
1704                 CREATE TABLE commit_guard_child (
1705                     event_id INTEGER PRIMARY KEY,
1706                     parent_id INTEGER NOT NULL,
1707                     FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
1708                         DEFERRABLE INITIALLY DEFERRED
1709                 );
1710                 CREATE TRIGGER fail_learning_commit
1711                 AFTER INSERT ON face_learning_events
1712                 BEGIN
1713                     INSERT INTO commit_guard_child (event_id, parent_id)
1714                     VALUES (NEW.id, 999);
1715                 END;",
1716            )
1717            .unwrap();
1718
1719            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1720            let state: (Option<String>, i64) = conn
1721                .query_row(
1722                    "SELECT person_label, confirmed FROM faces WHERE id = 6",
1723                    [],
1724                    |row| Ok((row.get(0)?, row.get(1)?)),
1725                )
1726                .unwrap();
1727            assert_eq!(state, (None, 0));
1728            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1729            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1730        }
1731
1732        #[test]
1733        fn malformed_or_mixed_prestate_rolls_back_without_learning() {
1734            let conn = learning_seed();
1735            conn.execute("UPDATE faces SET embedding = X'0000' WHERE id = 6", [])
1736                .unwrap();
1737            assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1738            assert!(new_person_with_learning(&conn, &[3, 7], "Bob", &context()).is_err());
1739            assert!(new_person_with_learning(&conn, &[1], "Bob", &context()).is_err());
1740            assert!(assign_with_learning(&conn, &[999], "alice", &context()).is_err());
1741            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1742            assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1743        }
1744
1745        #[test]
1746        fn deleting_a_person_invalidates_identity_evidence_without_a_negative_event() {
1747            let conn = learning_seed();
1748            assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1749            let acknowledgement = delete_person_with_learning(&conn, "alice")
1750                .unwrap()
1751                .unwrap();
1752            assert_eq!(acknowledgement.generation, 2);
1753            assert!(acknowledgement.event_ids.is_empty());
1754
1755            let events = list_learning_events(&conn, 20, None).unwrap();
1756            assert_eq!(events.len(), 1);
1757            assert!(!events[0].eligible);
1758            assert_eq!(
1759                events[0].invalidation_reason,
1760                Some(videre_core::face_learning::InvalidationReason::PersonRemoved)
1761            );
1762            assert!(delete_person_with_learning(&conn, "alice")
1763                .unwrap()
1764                .is_none());
1765            assert_eq!(learning_state(&conn).unwrap().generation, 2);
1766        }
1767
1768        #[test]
1769        fn deleting_a_person_without_learning_evidence_keeps_generation_current() {
1770            let conn = learning_seed();
1771            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1772
1773            let acknowledgement = delete_person_with_learning(&conn, "alice")
1774                .unwrap()
1775                .unwrap();
1776
1777            assert_eq!(acknowledgement.generation, 0);
1778            assert!(acknowledgement.event_ids.is_empty());
1779            assert_eq!(learning_state(&conn).unwrap().generation, 0);
1780            assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1781        }
1782    }
1783
1784    #[test]
1785    fn the_list_comes_back_in_the_same_order_every_time() {
1786        // The labeling UI re-fetches after every assignment, so an unstable
1787        // order means the cluster lined up next moves, and so does the person
1788        // being dragged onto. Both lists were collected straight out of a
1789        // HashMap, which discarded the ORDER BY in the queries above.
1790        // `singletons` never had the bug, and the only difference is that it is
1791        // built as a Vec.
1792        let conn = seed();
1793        // The seed has one cluster and one person, which cannot show an
1794        // ordering problem. Add enough of both to have an order at all, with
1795        // sizes deliberately not matching id order.
1796        conn.execute_batch(
1797            // Columns named explicitly: `seed` runs ensure_file_hashes_columns,
1798            // so the table has more than the two it was created with.
1799            "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
1800                ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
1801             INSERT INTO people (name, full_name) VALUES ('bob','Bob');
1802             INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1803                (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
1804                (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
1805                (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
1806                (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
1807                (10,'h10','0,0,9,9',X'0000',NULL,'bob',1,0);",
1808        )
1809        .unwrap();
1810
1811        // Two calls on one connection: each builds fresh HashMaps, and Rust
1812        // seeds them differently, so an unstable order shows up here.
1813        let a = faces_list(&conn).unwrap();
1814        let b = faces_list(&conn).unwrap();
1815
1816        let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
1817        let names =
1818            |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
1819        assert!(ids(&a).len() >= 3, "fixture must have several clusters");
1820        assert_eq!(
1821            ids(&a),
1822            ids(&b),
1823            "cluster order must not change between calls"
1824        );
1825        assert_eq!(
1826            names(&a),
1827            names(&b),
1828            "people order must not change between calls"
1829        );
1830
1831        // And the order is the useful one: biggest first, so the cluster worth
1832        // the most labelling effort is where it is expected.
1833        let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
1834        let mut want = sizes.clone();
1835        want.sort_unstable_by(|x, y| y.cmp(x));
1836        assert_eq!(
1837            sizes, want,
1838            "clusters must be ordered largest first, got {sizes:?}"
1839        );
1840    }
1841
1842    #[test]
1843    fn faces_list_splits_people_clusters_singletons() {
1844        let conn = seed();
1845        let d = faces_list(&conn).unwrap();
1846        assert_eq!(d.people.len(), 1);
1847        // Identity is the normalized form; what a reader sees is separate.
1848        assert_eq!(d.people[0].label, "alice");
1849        assert_eq!(d.people[0].full_name, "Alice");
1850        assert_eq!(
1851            d.people[0].representative_id, 1,
1852            "primary face is representative"
1853        );
1854        assert_eq!(d.clusters.len(), 1);
1855        assert_eq!(d.clusters[0].cluster_id, 7);
1856        assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
1857        assert_eq!(d.singletons.len(), 1);
1858        assert_eq!(d.singletons[0].face_id, 5);
1859    }
1860
1861    #[test]
1862    fn person_detail_marks_primary() {
1863        let conn = seed();
1864        let p = person_detail(&conn, "Alice").unwrap();
1865        assert_eq!(p.faces.len(), 2);
1866        assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
1867        assert!(!p.faces[1].is_primary);
1868    }
1869
1870    #[test]
1871    fn cluster_detail_lists_faces() {
1872        let conn = seed();
1873        let c = cluster_detail(&conn, 7).unwrap();
1874        assert_eq!(c.cluster_id, 7);
1875        assert_eq!(
1876            c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
1877            vec![3, 4]
1878        );
1879    }
1880
1881    #[test]
1882    fn assign_labels_and_confirms() {
1883        let conn = seed();
1884        assign(&conn, &[3, 4], "Bob").unwrap();
1885        let p = person_detail(&conn, "Bob").unwrap();
1886        assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
1887    }
1888
1889    #[test]
1890    fn assign_rejects_empty_label() {
1891        let conn = seed();
1892        assert!(matches!(assign(&conn, &[3], "   "), Err(Error::Invalid)));
1893    }
1894
1895    #[test]
1896    fn remove_face_unassigns_everything() {
1897        let conn = seed();
1898        remove_face(&conn, 1).unwrap();
1899        let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
1900            .query_row(
1901                "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
1902                [],
1903                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
1904            )
1905            .unwrap();
1906        assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
1907    }
1908
1909    #[test]
1910    fn dissolve_cluster_nulls_cluster_id() {
1911        let conn = seed();
1912        dissolve_cluster(&conn, 7).unwrap();
1913        assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
1914        assert_eq!(
1915            faces_list(&conn).unwrap().singletons.len(),
1916            3,
1917            "3,4 join 5 as singletons"
1918        );
1919    }
1920
1921    #[test]
1922    fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
1923        // A delete that matched zero faces is a no-op by design; it must not
1924        // schedule a whole-library regroup (watermark reset) for nothing.
1925        let conn = seed();
1926        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1927        let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
1928        assert!(before > 0);
1929        delete_person(&conn, "ghost").unwrap();
1930        assert_eq!(
1931            videre_core::face_db::recluster_watermark(&conn).unwrap(),
1932            before,
1933            "a no-op delete must not reopen the gated regroup"
1934        );
1935    }
1936
1937    #[test]
1938    fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
1939        // The real workflow: assign through the public path (which detaches
1940        // the cluster, per the frozen-faces contract), then delete the
1941        // person. The faces go back to the unassigned pool, and the recluster
1942        // watermark resets so the next gated pass regroups them: without the
1943        // reset, these pre-existing face ids sit below the watermark and the
1944        // gate stays closed forever.
1945        let conn = seed();
1946        assign(&conn, &[1, 2], "Alice").unwrap();
1947        assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
1948        // Simulate a completed recluster covering these faces: the watermark
1949        // sits at their ids, so the gate would stay closed for them forever.
1950        videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1951        assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
1952
1953        delete_person(&conn, "Alice").unwrap();
1954        assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
1955        assert_eq!(
1956            videre_core::face_db::recluster_watermark(&conn).unwrap(),
1957            0,
1958            "deleting a person must reopen the gated regroup for their faces"
1959        );
1960        let rows: Vec<(Option<i64>, Option<String>, i64)> = {
1961            let mut s = conn
1962                .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
1963                .unwrap();
1964            s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1965                .unwrap()
1966                .collect::<rusqlite::Result<_>>()
1967                .unwrap()
1968        };
1969        assert!(
1970            rows.iter()
1971                .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
1972            "every face returns to the unassigned pool: {rows:?}"
1973        );
1974    }
1975
1976    #[test]
1977    fn set_primary_is_exclusive_per_person() {
1978        let conn = seed();
1979        set_primary(&conn, 2, "Alice").unwrap();
1980        let primaries: Vec<i64> = {
1981            let mut s = conn
1982                .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
1983                .unwrap();
1984            s.query_map([], |r| r.get(0))
1985                .unwrap()
1986                .collect::<rusqlite::Result<_>>()
1987                .unwrap()
1988        };
1989        assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
1990    }
1991
1992    #[test]
1993    fn renaming_only_the_spelling_keeps_the_identity() {
1994        // The common rename: correcting or extending what is shown, which must
1995        // not change the URL or touch a single face row.
1996        let conn = seed();
1997        set_full_name(&conn, "alice", "Alice Smith").unwrap();
1998        let (name, full): (String, String) = conn
1999            .query_row("SELECT name, full_name FROM people", [], |r| {
2000                Ok((r.get(0)?, r.get(1)?))
2001            })
2002            .unwrap();
2003        assert_eq!(name, "alice", "identity is unchanged");
2004        assert_eq!(full, "Alice Smith", "only the display name moved");
2005        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
2006    }
2007
2008    // A write against a client-supplied id that matches no row is `Ok(0)` from
2009    // rusqlite, not an error. Reported as success it tells the labeling UI an
2010    // action worked when nothing changed. Each handler that takes an id from the
2011    // client must turn "matched nothing" into NotFound, the way set_full_name
2012    // already does.
2013
2014    #[test]
2015    fn assign_a_missing_face_is_not_found() {
2016        let conn = seed();
2017        assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
2018    }
2019
2020    #[test]
2021    fn assign_is_atomic_when_one_face_is_missing() {
2022        // face 3 exists, 999 does not. All-or-nothing: face 3 must be untouched
2023        // and no `Bob` person may be created, so a partial write can never be
2024        // reported as success.
2025        let conn = seed();
2026        assert!(matches!(
2027            assign(&conn, &[3, 999], "Bob"),
2028            Err(Error::NotFound)
2029        ));
2030        let (label, confirmed): (Option<String>, i64) = conn
2031            .query_row(
2032                "SELECT person_label, confirmed FROM faces WHERE id = 3",
2033                [],
2034                |r| Ok((r.get(0)?, r.get(1)?)),
2035            )
2036            .unwrap();
2037        assert_eq!(label, None, "face 3 must not have been labelled");
2038        assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
2039        let bob: i64 = conn
2040            .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
2041                r.get(0)
2042            })
2043            .unwrap();
2044        assert_eq!(
2045            bob, 0,
2046            "no person may be created when the assign rolls back"
2047        );
2048    }
2049
2050    #[test]
2051    fn assign_commit_failure_rolls_back_and_closes_the_transaction() {
2052        let conn = seed();
2053        conn.execute_batch(
2054            "PRAGMA foreign_keys = ON;
2055             CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
2056             CREATE TABLE commit_guard_child (
2057                 face_id INTEGER PRIMARY KEY,
2058                 parent_id INTEGER NOT NULL,
2059                 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
2060                     DEFERRABLE INITIALLY DEFERRED
2061             );
2062             CREATE TRIGGER fail_assign_commit
2063             AFTER UPDATE ON faces
2064             WHEN NEW.id = 3
2065             BEGIN
2066                 INSERT INTO commit_guard_child (face_id, parent_id)
2067                 VALUES (NEW.id, 999);
2068             END;",
2069        )
2070        .unwrap();
2071
2072        assert!(assign(&conn, &[3], "Bob").is_err());
2073        assert!(conn.is_autocommit());
2074        let state: (Option<String>, i64) = conn
2075            .query_row(
2076                "SELECT person_label, confirmed FROM faces WHERE id = 3",
2077                [],
2078                |row| Ok((row.get(0)?, row.get(1)?)),
2079            )
2080            .unwrap();
2081        assert_eq!(state, (None, 0));
2082        let bob: i64 = conn
2083            .query_row(
2084                "SELECT COUNT(*) FROM people WHERE name = 'bob'",
2085                [],
2086                |row| row.get(0),
2087            )
2088            .unwrap();
2089        assert_eq!(bob, 0);
2090    }
2091
2092    #[test]
2093    fn assign_rejects_empty_face_ids() {
2094        // Nothing to assign is a malformed request, not a silent success that
2095        // creates a person with no faces.
2096        let conn = seed();
2097        assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
2098    }
2099
2100    #[test]
2101    fn remove_face_missing_is_not_found() {
2102        let conn = seed();
2103        assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
2104    }
2105
2106    #[test]
2107    fn dissolve_cluster_missing_is_not_found() {
2108        let conn = seed();
2109        assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
2110    }
2111
2112    #[test]
2113    fn set_primary_missing_face_is_not_found() {
2114        let conn = seed();
2115        assert!(matches!(
2116            set_primary(&conn, 999, "Alice"),
2117            Err(Error::NotFound)
2118        ));
2119    }
2120
2121    #[test]
2122    fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
2123        // face 5 is an unassigned singleton, so the guarded update matches no
2124        // row for Alice. The failure must roll back the primary-clearing step:
2125        // Alice's existing primary (face 1) has to survive.
2126        let conn = seed();
2127        assert!(matches!(
2128            set_primary(&conn, 5, "Alice"),
2129            Err(Error::NotFound)
2130        ));
2131        let primary: i64 = conn
2132            .query_row(
2133                "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
2134                [],
2135                |r| r.get(0),
2136            )
2137            .unwrap();
2138        assert_eq!(
2139            primary, 1,
2140            "the original primary must be restored on rollback"
2141        );
2142    }
2143
2144    #[test]
2145    fn delete_person_missing_is_idempotent_success() {
2146        // Delete is idempotent: asking to unassign a person who is already gone
2147        // has already achieved its goal. A person can also legitimately have a
2148        // `people` row and no confirmed faces, which would make a row-count
2149        // check wrongly 404 a real person, so delete stays out of the NotFound
2150        // rule by design.
2151        let conn = seed();
2152        assert!(delete_person(&conn, "Nobody").is_ok());
2153    }
2154}
2155
2156#[cfg(test)]
2157mod identity_tests {
2158    use super::tests::seed;
2159    use super::*;
2160
2161    fn people(conn: &Connection) -> Vec<(String, String)> {
2162        conn.prepare("SELECT name, full_name FROM people ORDER BY name")
2163            .unwrap()
2164            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2165            .unwrap()
2166            .collect::<rusqlite::Result<_>>()
2167            .unwrap()
2168    }
2169
2170    #[test]
2171    fn assign_stores_the_identity_and_records_the_display_name() {
2172        let conn = seed();
2173        assign(&conn, &[3], "Işıl Özyeğin").unwrap();
2174
2175        let label: String = conn
2176            .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
2177                r.get(0)
2178            })
2179            .unwrap();
2180        assert_eq!(label, "isil_ozyegin", "faces hold the identity");
2181        assert!(
2182            people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
2183            "and the spelling is kept for display"
2184        );
2185    }
2186
2187    #[test]
2188    fn assigning_an_existing_name_in_another_case_joins_that_person() {
2189        // The bug this whole change exists to fix: this used to create a second
2190        // person.
2191        let conn = seed();
2192        assign(&conn, &[3], "ALICE").unwrap();
2193        assert_eq!(people(&conn).len(), 1, "still one person, not two");
2194        assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
2195        assert_eq!(
2196            people(&conn)[0].1,
2197            "Alice",
2198            "the existing spelling is not overwritten by the new casing"
2199        );
2200    }
2201
2202    #[test]
2203    fn assign_rejects_a_name_with_no_usable_identity() {
2204        // Punctuation alone leaves nothing to identify a person by, and an
2205        // empty identity would be a person nobody could address.
2206        let conn = seed();
2207        assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
2208    }
2209
2210    #[test]
2211    fn person_detail_resolves_every_form_of_the_name() {
2212        let conn = seed();
2213        for form in ["alice", "Alice", "ALICE", "  alice  "] {
2214            assert_eq!(
2215                person_detail(&conn, form).unwrap().faces.len(),
2216                2,
2217                "form {form:?}"
2218            );
2219        }
2220    }
2221
2222    #[test]
2223    fn person_detail_reports_the_display_name() {
2224        let d = person_detail(&seed(), "alice").unwrap();
2225        assert_eq!(d.label, "alice");
2226        assert_eq!(d.full_name, "Alice");
2227    }
2228
2229    #[test]
2230    fn person_detail_falls_back_when_there_is_no_people_row() {
2231        // A label written before the table existed still has to render. The
2232        // orphan label is the pre-v2 shape, so its seed runs with enforcement
2233        // lifted and restored.
2234        let conn = seed();
2235        conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap();
2236        conn.execute(
2237            "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
2238             VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
2239            [],
2240        )
2241        .unwrap();
2242        conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
2243        let d = person_detail(&conn, "orphan").unwrap();
2244        assert_eq!(d.full_name, "orphan", "falls back to the identity");
2245    }
2246
2247    #[test]
2248    fn set_full_name_changes_only_the_display_name() {
2249        let conn = seed();
2250        set_full_name(&conn, "alice", "Alice Smith").unwrap();
2251        assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
2252        assert_eq!(
2253            person_detail(&conn, "alice").unwrap().faces.len(),
2254            2,
2255            "no face was touched"
2256        );
2257    }
2258
2259    #[test]
2260    fn set_full_name_accepts_any_form_of_the_identity() {
2261        let conn = seed();
2262        set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
2263        assert_eq!(people(&conn)[0].1, "Alice Smith");
2264    }
2265
2266    #[test]
2267    fn set_full_name_on_a_missing_person_is_not_found() {
2268        assert!(matches!(
2269            set_full_name(&seed(), "nobody", "Someone"),
2270            Err(Error::NotFound)
2271        ));
2272    }
2273
2274    #[test]
2275    fn set_full_name_rejects_an_empty_display_name() {
2276        // A person with no name to show is worse than one shown by identity.
2277        assert!(matches!(
2278            set_full_name(&seed(), "alice", "   "),
2279            Err(Error::Invalid)
2280        ));
2281    }
2282
2283    #[test]
2284    fn delete_person_accepts_any_form_of_the_name() {
2285        let conn = seed();
2286        delete_person(&conn, "Alice").unwrap();
2287        let left: i64 = conn
2288            .query_row(
2289                "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
2290                [],
2291                |r| r.get(0),
2292            )
2293            .unwrap();
2294        assert_eq!(left, 0, "faces are unassigned whichever form was passed");
2295    }
2296
2297    #[test]
2298    fn set_primary_accepts_any_form_of_the_name() {
2299        let conn = seed();
2300        set_primary(&conn, 2, "ALICE").unwrap();
2301        let primary: i64 = conn
2302            .query_row(
2303                "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
2304                [],
2305                |r| r.get(0),
2306            )
2307            .unwrap();
2308        assert_eq!(primary, 2);
2309    }
2310}
2311
2312#[cfg(test)]
2313mod never_run_tests {
2314    use super::*;
2315
2316    /// :warning: **A scanned-but-never-detected library has no `faces` table.**
2317    ///
2318    /// `videre scan` creates `file_hashes`, `people` and `pipeline_runs`. The
2319    /// faces table arrives with the first `videre faces` run, so every query
2320    /// here failed with "no such table" until then. The server turned that into
2321    /// a 500 with an empty body, and the page turned the empty body into
2322    /// `Unexpected end of JSON input` across the top of the labeling UI.
2323    ///
2324    /// Every existing test in this file seeds a faces table, which is why none
2325    /// of them could see it: they all describe a library that has already run
2326    /// detection.
2327    #[test]
2328    fn a_library_that_never_ran_detection_is_empty_not_an_error() {
2329        let conn = Connection::open_in_memory().unwrap();
2330        conn.execute_batch(
2331            "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
2332             CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
2333        )
2334        .unwrap();
2335
2336        let data = faces_list(&conn).expect("a library with no faces table is not an error");
2337        assert!(data.people.is_empty());
2338        assert!(data.clusters.is_empty());
2339        assert!(data.singletons.is_empty());
2340    }
2341
2342    // ---- questions ----
2343
2344    mod question_fixture {
2345        use super::*;
2346        use videre_core::face_learning::{
2347            ensure_question_tables, replace_pending_questions, select_questions, LogisticModel,
2348            LogisticScorer, ModelBundle, QuestionSelectionConfig, MEMBERSHIP_FEATURE_NAMES,
2349            MODEL_ARTIFACT_VERSION,
2350        };
2351
2352        pub fn embedding_blob(x: f32, y: f32) -> Vec<u8> {
2353            let mut bytes = Vec::with_capacity(4);
2354            bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
2355            bytes.extend_from_slice(&half::f16::from_f32(y).to_le_bytes());
2356            bytes
2357        }
2358
2359        fn logistic_bundle() -> ModelBundle {
2360            let names: Vec<String> = MEMBERSHIP_FEATURE_NAMES
2361                .iter()
2362                .map(|name| name.to_string())
2363                .collect();
2364            let means: Vec<f64> = names
2365                .iter()
2366                .map(|name| if name == "similarity_mean" { 1.0 } else { 0.0 })
2367                .collect();
2368            let scales: Vec<f64> = names
2369                .iter()
2370                .map(|name| if name == "similarity_mean" { 0.5 } else { 1.0 })
2371                .collect();
2372            let weights: Vec<f64> = names
2373                .iter()
2374                .map(|name| if name == "similarity_mean" { 2.0 } else { 0.0 })
2375                .collect();
2376            let scorer = LogisticScorer {
2377                model: LogisticModel {
2378                    feature_names: names,
2379                    means,
2380                    scales,
2381                    intercept: 0.0,
2382                    weights,
2383                    l2: 1.0,
2384                    positive_class_weight: 1.0,
2385                },
2386                calibration: videre_core::face_learning::CalibrationModel {
2387                    intercept: 0.0,
2388                    slope: 1.0,
2389                },
2390                threshold: 0.5,
2391            };
2392            ModelBundle::Logistic {
2393                artifact_version: MODEL_ARTIFACT_VERSION,
2394                embedding_model_id: "arcface/test".into(),
2395                feature_schema_version: 1,
2396                membership: scorer.clone(),
2397                cluster_quality: scorer,
2398            }
2399        }
2400
2401        /// Faces 10 and 11 sit in cluster 1; faces 12 and 13 confirm "alice".
2402        /// Returns the connection and the pending question id asking about
2403        /// cluster 1 and alice.
2404        /// Mirrors the planned foreign-key contract: enforcement on, and a face
2405        /// label must name an existing person.
2406        pub fn library() -> (Connection, i64, i64) {
2407            let conn = Connection::open_in_memory().unwrap();
2408            conn.execute_batch(
2409                "PRAGMA foreign_keys = ON;
2410                 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
2411                 CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
2412                 bbox TEXT NOT NULL, landmark TEXT, embedding BLOB NOT NULL,
2413                 cluster_id INTEGER,
2414                 person_label TEXT REFERENCES people(name) ON DELETE RESTRICT ON UPDATE RESTRICT,
2415                 confirmed INTEGER DEFAULT 0,
2416                 is_primary INTEGER DEFAULT 0, det_score REAL, blur REAL, oriented INTEGER);",
2417            )
2418            .unwrap();
2419            videre_core::face_learning::ensure_learning_tables(&conn).unwrap();
2420            videre_core::face_learning::ensure_profile_table(&conn).unwrap();
2421            ensure_question_tables(&conn).unwrap();
2422
2423            for (id, cluster) in [(10, Some(1)), (11, Some(1)), (12, None), (13, None)] {
2424                conn.execute(
2425                    "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2426                     VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, 0.9, 600.0)",
2427                    rusqlite::params![id, embedding_blob(1.0, 0.0), cluster],
2428                )
2429                .unwrap();
2430            }
2431            assign(&conn, &[12, 13], "Alice").unwrap();
2432
2433            let evidence =
2434                serde_json::to_string(&videre_core::face_learning::TrainingEvidenceCounts {
2435                    positive_pairs: 20,
2436                    negative_pairs: 20,
2437                    explicit_negative_pairs: 0,
2438                })
2439                .unwrap();
2440            let report = serde_json::to_string(&videre_core::face_learning::ValidationReport {
2441                protocol_version: 1,
2442                evidence_schema_version: 1,
2443                feature_schema_version: 1,
2444                datasets: Vec::new(),
2445            })
2446            .unwrap();
2447            conn.execute(
2448                "INSERT INTO face_learning_profiles (
2449                    artifact_version, embedding_model_id, feature_schema_version, model_kind,
2450                    parameters, training_evidence_json, validation_report_json, stage, status
2451                 ) VALUES (1, 'arcface/test', 1, 'logistic', ?1, ?2, ?3, 'suggestion', 'active')",
2452                rusqlite::params![
2453                    serde_json::to_vec(&logistic_bundle()).unwrap(),
2454                    evidence,
2455                    report
2456                ],
2457            )
2458            .unwrap();
2459            let profile_id = conn.last_insert_rowid();
2460
2461            let candidates = select_questions(&conn, &QuestionSelectionConfig::default()).unwrap();
2462            assert_eq!(candidates.len(), 1, "fixture must produce one question");
2463            let stored = replace_pending_questions(&conn, &candidates).unwrap();
2464            assert_eq!(stored.len(), 1);
2465            (conn, stored[0].id, profile_id)
2466        }
2467
2468        pub fn stub_evidence() -> videre_core::face_learning::DecisionEvidence {
2469            use videre_core::face_learning::{
2470                Calibration, DecisionKind, DecisionOutcome, DecisionTarget, FeatureContribution,
2471                ValidationSummary, EVIDENCE_SCHEMA_VERSION, FEATURE_SCHEMA_VERSION,
2472            };
2473            let evidence = videre_core::face_learning::DecisionEvidence {
2474                schema_version: EVIDENCE_SCHEMA_VERSION,
2475                profile_id: 1,
2476                feature_schema_version: FEATURE_SCHEMA_VERSION,
2477                decision_kind: DecisionKind::Membership,
2478                outcome: DecisionOutcome::Allowed,
2479                subject_face_ids: vec![10],
2480                target: DecisionTarget::Person("alice".into()),
2481                intercept: 0.0,
2482                raw_logit: 0.0,
2483                calibration: Calibration {
2484                    intercept: 0.0,
2485                    slope: 1.0,
2486                },
2487                calibrated_confidence: 0.5,
2488                threshold: 0.5,
2489                margin: 0.0,
2490                features: vec![FeatureContribution {
2491                    name: "similarity_mean".into(),
2492                    value: 1.0,
2493                    contribution: 0.0,
2494                }],
2495                support_face_ids: vec![12, 13],
2496                rule_vetoes: Vec::new(),
2497                validation: ValidationSummary {
2498                    protocol_version: 1,
2499                    datasets: 1,
2500                    pair_precision: None,
2501                    pair_recall: None,
2502                    suggestion_precision: None,
2503                    suggestion_coverage: None,
2504                },
2505            };
2506            evidence.validate().unwrap();
2507            evidence
2508        }
2509
2510        pub fn context(profile_id: i64) -> TeachingContext {
2511            TeachingContext {
2512                embedding_model_id: "arcface/test".into(),
2513                active_profile_id: Some(profile_id),
2514            }
2515        }
2516    }
2517
2518    use question_fixture as qf;
2519
2520    #[test]
2521    fn deleting_a_person_supersedes_questions_and_advances_once() {
2522        let (conn, _question_id, _profile_id) = qf::library();
2523        // A second pending question for the same identity: both must go.
2524        let second = videre_core::face_learning::StoredQuestion {
2525            id: 999,
2526            status: videre_core::face_learning::QuestionStatus::Pending,
2527            subject_face_ids: vec![10],
2528            support_face_ids: vec![12, 13],
2529            target_identity: "alice".into(),
2530            target_display: "Alice".into(),
2531            profile_id: 1,
2532            model_kind: "logistic".into(),
2533            representative_face_id: 10,
2534            cluster_id: 1,
2535            evidence_revision: "another-revision".into(),
2536            evidence: qf::stub_evidence(),
2537            created_at: "2026-01-01 00:00:00".into(),
2538            decided_at: None,
2539        };
2540        let _ = second;
2541        delete_person_with_learning(&conn, "Alice").unwrap();
2542        let superseded: i64 = conn
2543            .query_row(
2544                "SELECT count(*) FROM face_learning_questions WHERE status = 'superseded'",
2545                [],
2546                |row| row.get(0),
2547            )
2548            .unwrap();
2549        assert_eq!(superseded, 1, "the pending question must be superseded");
2550        let state = learning_state(&conn).unwrap();
2551        assert_eq!(state.generation, 1, "exactly one generation advance");
2552        let invalidated: i64 = conn
2553            .query_row(
2554                "SELECT count(*) FROM face_learning_events WHERE eligible = 0",
2555                [],
2556                |row| row.get(0),
2557            )
2558            .unwrap();
2559        assert_eq!(invalidated, 0, "no events existed to invalidate");
2560    }
2561
2562    #[test]
2563    fn the_journal_reports_availability_without_rewriting_history() {
2564        let (conn, _question_id, profile_id) = qf::library();
2565        // Produce journal entries, then remove a face an entry points at.
2566        assign_with_learning(&conn, &[10, 11], "Alice", &qf::context(profile_id)).unwrap();
2567        let subject_event_id = face_learning_events(&conn, 50, None, Some("arcface/test"))
2568            .unwrap()
2569            .iter()
2570            .find(|proof| proof.event.faces.iter().any(|face| face.face_id == 10))
2571            .map(|proof| proof.event.id)
2572            .unwrap();
2573        // A re-detection rebuild replaces face rows; simulate the row the
2574        // entry references vanishing.
2575        conn.execute("DELETE FROM faces WHERE id = 10", []).unwrap();
2576
2577        let proofs = face_learning_events(&conn, 50, None, Some("arcface/test")).unwrap();
2578        let proof = proofs
2579            .iter()
2580            .find(|proof| proof.event.id == subject_event_id)
2581            .unwrap();
2582        assert!(!proof.source_available, "the subject face is gone");
2583        assert!(!proof.incompatible, "same model and schema stay usable");
2584        assert!(proof.event.eligible, "missing provenance stays eligible");
2585
2586        // A different configured model marks the entry incompatible.
2587        let proofs = face_learning_events(&conn, 50, None, Some("other/model")).unwrap();
2588        let proof = proofs
2589            .iter()
2590            .find(|proof| proof.event.id == subject_event_id)
2591            .unwrap();
2592        assert!(proof.incompatible);
2593
2594        // Invalidation changes eligibility columns only, never the features.
2595        let (conn, question_id, profile_id) = qf::library();
2596        answer_question_with_learning(
2597            &conn,
2598            question_id,
2599            videre_core::face_learning::QuestionAnswer::No,
2600            &qf::context(profile_id),
2601        )
2602        .unwrap();
2603        let before: String = conn
2604            .query_row(
2605                "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2606                [],
2607                |row| row.get(0),
2608            )
2609            .unwrap();
2610        delete_person_with_learning(&conn, "Alice").unwrap();
2611        let after: String = conn
2612            .query_row(
2613                "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2614                [],
2615                |row| row.get(0),
2616            )
2617            .unwrap();
2618        assert_eq!(before, after, "historical feature JSON never mutates");
2619    }
2620
2621    #[test]
2622    fn yes_confirms_the_target_and_teaches_positive_membership() {
2623        let (conn, question_id, profile_id) = qf::library();
2624        let outcome = answer_question_with_learning(
2625            &conn,
2626            question_id,
2627            QuestionAnswer::Yes,
2628            &qf::context(profile_id),
2629        )
2630        .unwrap();
2631        assert_eq!(outcome.status, "answered");
2632        let ack = outcome.acknowledgement.expect("yes must teach");
2633        assert_eq!(ack.event_ids.len(), 1);
2634        assert_eq!(ack.generation, 1);
2635
2636        let labeled: i64 = conn
2637            .query_row(
2638                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND person_label = 'alice'
2639                 AND confirmed = 1 AND cluster_id IS NULL",
2640                [],
2641                |row| row.get(0),
2642            )
2643            .unwrap();
2644        assert_eq!(labeled, 2, "yes labels the whole subject cluster");
2645
2646        let event: (String, String, String) = conn
2647            .query_row(
2648                "SELECT action_kind, outcome, target_identity FROM face_learning_events",
2649                [],
2650                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2651            )
2652            .unwrap();
2653        assert_eq!(event.0, "question_yes");
2654        assert_eq!(event.1, "positive");
2655        assert_eq!(event.2, "alice");
2656    }
2657
2658    #[test]
2659    fn no_teaches_negative_without_labeling() {
2660        let (conn, question_id, profile_id) = qf::library();
2661        let outcome = answer_question_with_learning(
2662            &conn,
2663            question_id,
2664            QuestionAnswer::No,
2665            &qf::context(profile_id),
2666        )
2667        .unwrap();
2668        assert_eq!(outcome.status, "answered");
2669
2670        let untouched: i64 = conn
2671            .query_row(
2672                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 0
2673                 AND person_label IS NULL AND cluster_id = 1",
2674                [],
2675                |row| row.get(0),
2676            )
2677            .unwrap();
2678        assert_eq!(untouched, 2, "no must not label");
2679
2680        let event: (String, String) = conn
2681            .query_row(
2682                "SELECT action_kind, outcome FROM face_learning_events",
2683                [],
2684                |row| Ok((row.get(0)?, row.get(1)?)),
2685            )
2686            .unwrap();
2687        assert_eq!(event.0, "question_no");
2688        assert_eq!(event.1, "negative");
2689    }
2690
2691    #[test]
2692    fn skip_only_changes_delivery_state() {
2693        let (conn, question_id, profile_id) = qf::library();
2694        let outcome = answer_question_with_learning(
2695            &conn,
2696            question_id,
2697            QuestionAnswer::Skip,
2698            &qf::context(profile_id),
2699        )
2700        .unwrap();
2701        assert_eq!(outcome.status, "skipped");
2702        assert!(outcome.acknowledgement.is_none());
2703
2704        let events: i64 = conn
2705            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2706                row.get(0)
2707            })
2708            .unwrap();
2709        assert_eq!(events, 0, "skip produces no event");
2710        let state = learning_state(&conn).unwrap();
2711        assert_eq!(state.generation, 0, "skip does not advance generation");
2712    }
2713
2714    #[test]
2715    fn stale_answers_conflict_without_partial_writes() {
2716        // Already-labeled subject.
2717        let (conn, question_id, profile_id) = qf::library();
2718        assign(&conn, &[10, 11], "Bob").unwrap();
2719        assert!(matches!(
2720            answer_question_with_learning(
2721                &conn,
2722                question_id,
2723                QuestionAnswer::Yes,
2724                &qf::context(profile_id)
2725            ),
2726            Err(Error::Conflict)
2727        ));
2728        let events: i64 = conn
2729            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2730                row.get(0)
2731            })
2732            .unwrap();
2733        assert_eq!(events, 0, "a conflict must not teach");
2734        assert_eq!(
2735            videre_core::face_learning::stored_question(&conn, question_id)
2736                .unwrap()
2737                .unwrap()
2738                .status,
2739            QuestionStatus::Superseded
2740        );
2741
2742        // Removed target person. With face labels referencing people, the
2743        // row can only go once no face carries the label.
2744        let (conn, question_id, profile_id) = qf::library();
2745        conn.execute_batch(
2746            "UPDATE faces SET person_label = NULL, confirmed = 0 WHERE person_label = 'alice';
2747             DELETE FROM people WHERE name = 'alice';",
2748        )
2749        .unwrap();
2750        assert!(matches!(
2751            answer_question_with_learning(
2752                &conn,
2753                question_id,
2754                QuestionAnswer::No,
2755                &qf::context(profile_id)
2756            ),
2757            Err(Error::Conflict)
2758        ));
2759        assert_eq!(
2760            videre_core::face_learning::stored_question(&conn, question_id)
2761                .unwrap()
2762                .unwrap()
2763                .status,
2764            QuestionStatus::Superseded
2765        );
2766
2767        // A different active profile.
2768        let (conn, question_id, profile_id) = qf::library();
2769        conn.execute("UPDATE face_learning_profiles SET status = 'retired'", [])
2770            .unwrap();
2771        let _ = profile_id;
2772        assert!(matches!(
2773            answer_question_with_learning(&conn, question_id, QuestionAnswer::No, &qf::context(99)),
2774            Err(Error::Conflict)
2775        ));
2776        assert_eq!(
2777            videre_core::face_learning::stored_question(&conn, question_id)
2778                .unwrap()
2779                .unwrap()
2780                .status,
2781            QuestionStatus::Superseded
2782        );
2783
2784        // Support set changed: the evidence revision no longer matches.
2785        let (conn, question_id, profile_id) = qf::library();
2786        assign(&conn, &[13], "Alice").unwrap();
2787        remove_face(&conn, 12).unwrap();
2788        insert_face_with_score(&conn, 14, None, 0.9);
2789        assign(&conn, &[14], "Alice").unwrap();
2790        assert!(matches!(
2791            answer_question_with_learning(
2792                &conn,
2793                question_id,
2794                QuestionAnswer::No,
2795                &qf::context(profile_id)
2796            ),
2797            Err(Error::Conflict)
2798        ));
2799        let question = videre_core::face_learning::stored_question(&conn, question_id)
2800            .unwrap()
2801            .unwrap();
2802        assert_eq!(
2803            question.status,
2804            videre_core::face_learning::QuestionStatus::Superseded
2805        );
2806        assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2807    }
2808
2809    fn insert_face_with_score(conn: &Connection, id: i64, cluster: Option<i64>, score: f64) {
2810        conn.execute(
2811            "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2812             VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, ?4, 600.0)",
2813            rusqlite::params![id, qf::embedding_blob(1.0, 0.0), cluster, score],
2814        )
2815        .unwrap();
2816    }
2817
2818    #[test]
2819    fn faces_moved_out_of_the_question_cluster_conflict() {
2820        let (conn, question_id, profile_id) = qf::library();
2821        // A recluster reassigned one subject face after the question was
2822        // built: the evidence no longer describes the displayed cluster.
2823        conn.execute("UPDATE faces SET cluster_id = 9 WHERE id = 11", [])
2824            .unwrap();
2825        assert!(matches!(
2826            answer_question_with_learning(
2827                &conn,
2828                question_id,
2829                QuestionAnswer::Yes,
2830                &qf::context(profile_id)
2831            ),
2832            Err(Error::Conflict)
2833        ));
2834
2835        let labeled: i64 = conn
2836            .query_row(
2837                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
2838                [],
2839                |row| row.get(0),
2840            )
2841            .unwrap();
2842        assert_eq!(labeled, 0, "a stale cluster must not label");
2843        let events: i64 = conn
2844            .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2845                row.get(0)
2846            })
2847            .unwrap();
2848        assert_eq!(events, 0);
2849        let question = videre_core::face_learning::stored_question(&conn, question_id)
2850            .unwrap()
2851            .unwrap();
2852        assert_eq!(
2853            question.status,
2854            videre_core::face_learning::QuestionStatus::Superseded
2855        );
2856        assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2857    }
2858
2859    #[test]
2860    fn refresh_creates_question_tables_for_a_first_training_cycle() {
2861        let (conn, _, _) = qf::library();
2862        conn.execute_batch(
2863            "DROP TABLE face_learning_question_faces;
2864             DROP TABLE face_learning_questions;",
2865        )
2866        .unwrap();
2867
2868        let questions = refresh_identity_questions(&conn, &QuestionSelectionConfig::default())
2869            .expect("a promoted profile should create the question tables");
2870        assert_eq!(questions.len(), 1);
2871        assert_eq!(pending_identity_questions(&conn, 5).unwrap().len(), 1);
2872    }
2873
2874    /// A real initialized version-2 library: foreign keys verified on, the
2875    /// canonical DDL in place. The public face paths must work unchanged and
2876    /// orphan writes must fail.
2877    #[test]
2878    fn v2_library_enforces_keys_through_the_public_paths() {
2879        use videre_core::face_learning::QuestionAnswer as Answer;
2880        let root = tempfile::tempdir().unwrap();
2881        let cache = tempfile::tempdir().unwrap();
2882        let ctx = videre_core::library::LibraryContext::new(root.path(), cache.path()).unwrap();
2883        let conn = videre_core::library_db::initialize(&ctx).unwrap();
2884        let keys_on: i64 = conn
2885            .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
2886            .unwrap();
2887        assert_eq!(keys_on, 1, "an initialized library verifies enforcement");
2888
2889        // Seed two people with one confirmed face each, through the public
2890        // assign path, after detectable faces exist as unassigned clusters.
2891        conn.execute_batch(
2892            "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur) VALUES
2893                (1, 'k1', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0),
2894                (2, 'k2', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0);
2895             INSERT INTO people (name, full_name) VALUES ('alice', 'Alice'), ('bob', 'Bob');",
2896        )
2897        .unwrap();
2898        assign(&conn, &[1], "Alice").unwrap();
2899        assign(&conn, &[2], "Bob").unwrap();
2900
2901        // Orphan writes fail: a face labeled with an unknown person, and an
2902        // event provenance row with no parent event.
2903        assert!(conn
2904            .execute(
2905                "INSERT INTO faces (hash,bbox,embedding,person_label,confirmed)
2906                 VALUES ('k9','0,0,9,9',X'0000','ghost',1)",
2907                [],
2908            )
2909            .is_err());
2910        assert!(conn
2911            .execute(
2912                "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2913                 VALUES (999, 1, 'subject', 0)",
2914                [],
2915            )
2916            .is_err());
2917
2918        // Parent-first inserts succeed.
2919        conn.execute(
2920            "INSERT INTO face_learning_events (id, action_kind, decision_kind, outcome,
2921                embedding_model_id, feature_schema_version, target_identity,
2922                feature_snapshot_json, support_count)
2923             VALUES (1, 'assign_face', 'membership', 'positive', 'x/1', 1, 'alice', '{}', 0)",
2924            [],
2925        )
2926        .unwrap();
2927        conn.execute(
2928            "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2929             VALUES (1, 1, 'subject', 0)",
2930            [],
2931        )
2932        .unwrap();
2933
2934        // Deleting a person invalidates their evidence; the face the user
2935        // assigned becomes unassigned again.
2936        delete_person_with_learning(&conn, "Alice").unwrap();
2937        let state: (i64, Option<String>) = conn
2938            .query_row(
2939                "SELECT confirmed, person_label FROM faces WHERE id = 1",
2940                [],
2941                |r| Ok((r.get(0)?, r.get(1)?)),
2942            )
2943            .unwrap();
2944        assert_eq!(state, (0, None));
2945
2946        // A stale question is rejected by the answer path.
2947        let question = videre_core::face_learning::select_questions(
2948            &conn,
2949            &videre_core::face_learning::QuestionSelectionConfig::default(),
2950        )
2951        .unwrap();
2952        if !question.is_empty() {
2953            let stored =
2954                videre_core::face_learning::replace_pending_questions(&conn, &question).unwrap();
2955            conn.execute(
2956                "UPDATE face_learning_questions SET evidence_revision = 'stale' WHERE id = ?1",
2957                rusqlite::params![stored[0].id],
2958            )
2959            .unwrap();
2960            let context = TeachingContext {
2961                embedding_model_id: "x/1".into(),
2962                active_profile_id: None,
2963            };
2964            assert!(matches!(
2965                answer_question_with_learning(&conn, stored[0].id, Answer::Yes, &context),
2966                Err(Error::Conflict)
2967            ));
2968        }
2969
2970        // Reset clears every learning table and passes foreign_key_check.
2971        videre_core::face_db::reset_all(&conn).unwrap();
2972        for table in [
2973            "face_learning_events",
2974            "face_learning_event_faces",
2975            "face_learning_questions",
2976            "face_learning_question_faces",
2977            "face_learning_profiles",
2978        ] {
2979            let n: i64 = conn
2980                .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
2981                .unwrap();
2982            assert_eq!(n, 0, "{table} must be empty after reset");
2983        }
2984        let violations: i64 = conn
2985            .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
2986                r.get(0)
2987            })
2988            .unwrap();
2989        assert_eq!(violations, 0);
2990    }
2991
2992    #[test]
2993    fn yes_cannot_label_without_evidence() {
2994        let (conn, question_id, profile_id) = qf::library();
2995        conn.execute_batch(
2996            "CREATE TRIGGER abort_question_events
2997             BEFORE INSERT ON face_learning_events
2998             BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;",
2999        )
3000        .unwrap();
3001        assert!(answer_question_with_learning(
3002            &conn,
3003            question_id,
3004            QuestionAnswer::Yes,
3005            &qf::context(profile_id)
3006        )
3007        .is_err());
3008
3009        let labeled: i64 = conn
3010            .query_row(
3011                "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
3012                [],
3013                |row| row.get(0),
3014            )
3015            .unwrap();
3016        assert_eq!(labeled, 0, "yes cannot label without its evidence row");
3017
3018        let question = videre_core::face_learning::stored_question(&conn, question_id)
3019            .unwrap()
3020            .unwrap();
3021        assert_eq!(
3022            question.status,
3023            videre_core::face_learning::QuestionStatus::Pending
3024        );
3025    }
3026}