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