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