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