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