1use 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
189fn 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
200pub 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 "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 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
320pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
322 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
347pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
349 let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
353 let name = name.as_str();
354 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 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
388pub 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
397pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
400 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 if face_ids.is_empty() {
408 return Err(Error::Invalid);
409 }
410 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 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
577fn 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
662pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
666 assign(conn, face_ids, label)
667}
668
669pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
671 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
773pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
775 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
839pub 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 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
907pub 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 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
1065pub 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
1074pub 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
1085pub 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 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 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 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
1159fn 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
1203pub 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
1220fn 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#[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
1271pub 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
1300pub 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
1309pub 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
1339pub 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
1359pub 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 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
1411pub 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 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 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 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 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 #[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 #[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 #[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 let conn = seed();
2227 conn.execute_batch(
2231 "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 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 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 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 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 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 let conn = seed();
2415 assign(&conn, &[1, 2], "Alice").unwrap();
2416 assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
2417 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 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 #[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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}