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