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) -> Result<TrainedProfileSummary> {
1367 let validation = match run.comparison.selected {
1368 videre_core::face_learning::CandidateKind::Logistic => &run.logistic_validation,
1369 videre_core::face_learning::CandidateKind::Additive => &run.additive_validation,
1370 };
1371 let profile = videre_core::face_learning::NewProfile {
1372 artifact_version: videre_core::face_learning::PROFILE_ARTIFACT_VERSION,
1373 embedding_model_id: embedding_model_id.to_owned(),
1374 feature_schema_version: videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1375 model_kind: run.selected.model_kind().to_owned(),
1376 parameters: serde_json::to_vec(&run.selected).map_err(Error::from)?,
1377 training_evidence: run.evidence_counts.clone(),
1378 validation_report: validation.clone(),
1379 stage: videre_core::face_learning::ProfileStage::Suggestion,
1380 };
1381 let profile_id = videre_core::face_learning::insert_candidate(conn, &profile)?;
1382 let outcome = videre_core::face_learning::evaluate_and_promote(conn, profile_id, gates)?;
1383 Ok(TrainedProfileSummary {
1384 profile_id,
1385 model_kind: profile.model_kind,
1386 promoted: outcome == videre_core::face_learning::PromotionOutcome::Promoted,
1387 })
1388}
1389
1390pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
1395 let person_label =
1396 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
1397 conn.execute_batch("BEGIN")?;
1398 let result = (|| -> Result<()> {
1399 conn.execute(
1400 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
1401 rusqlite::params![person_label],
1402 )?;
1403 let n = conn.execute(
1408 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
1409 rusqlite::params![person_label, face_id],
1410 )?;
1411 if n == 0 {
1412 return Err(Error::NotFound);
1413 }
1414 Ok(())
1415 })();
1416 match result {
1417 Ok(()) => {
1418 conn.execute_batch("COMMIT")?;
1419 Ok(())
1420 }
1421 Err(e) => {
1422 let _ = conn.execute_batch("ROLLBACK");
1423 Err(e)
1424 }
1425 }
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430 use super::*;
1431
1432 #[test]
1433 fn assign_detaches_the_face_from_its_cluster() {
1434 let conn = seed();
1435 assign(&conn, &[3], "Bob").unwrap();
1438 let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
1439 .query_row(
1440 "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
1441 [],
1442 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1443 )
1444 .unwrap();
1445 assert_eq!(label.as_deref(), Some("bob"));
1446 assert_eq!(confirmed, 1);
1447 assert_eq!(cid, None, "assignment must detach the machine grouping");
1448 }
1449
1450 #[test]
1451 fn cluster_detail_never_shows_labeled_faces() {
1452 let conn = seed();
1453 conn.execute(
1457 "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
1458 (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
1459 [],
1460 )
1461 .unwrap();
1462 conn.execute(
1463 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
1464 [],
1465 )
1466 .unwrap();
1467 let detail = cluster_detail(&conn, 7).unwrap();
1468 assert_eq!(
1469 detail.faces.len(),
1470 2,
1471 "only the unlabeled faces of cluster 7 belong on the page"
1472 );
1473 }
1474
1475 pub(super) fn seed() -> Connection {
1482 let conn = Connection::open_in_memory().unwrap();
1483 videre_core::face_db::create_faces_table(&conn).unwrap();
1484 conn.execute_batch(
1485 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1486 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
1487 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
1488 -- Labels are stored in identity form, as `assign` writes them and
1489 -- as the migration leaves them; `people` carries what a reader
1490 -- sees. Seeding raw 'Alice' would test a state the application no
1491 -- longer produces.
1492 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
1493 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1494 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
1495 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
1496 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
1497 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
1498 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
1499 )
1500 .unwrap();
1501 videre_core::library_db::ensure_scan_schema(&conn).unwrap();
1502 conn
1503 }
1504
1505 mod learning {
1506 use super::*;
1507 use videre_core::face_learning::{
1508 learning_state, list_learning_events, LearningAction, LearningDecisionKind,
1509 LearningOutcome,
1510 };
1511
1512 fn context() -> TeachingContext {
1513 TeachingContext {
1514 embedding_model_id: "buffalo_l/w600k_r50.onnx".to_owned(),
1515 active_profile_id: None,
1516 record: true,
1517 }
1518 }
1519
1520 #[test]
1521 fn a_repeated_face_is_rejected_with_a_reason() {
1522 let conn = seed();
1523 let err = new_person_with_learning(&conn, &[3, 3, 4], "Bob", &context()).unwrap_err();
1524 assert_eq!(err.to_string(), "the request lists face 3 more than once");
1525 }
1526
1527 #[test]
1528 fn a_partial_cluster_is_rejected_with_the_counts() {
1529 let conn = seed();
1530 conn.execute(
1531 "INSERT INTO faces (id,hash,bbox,embedding,cluster_id) VALUES (6,'h5','1,1,9,9',X'0000',7)",
1532 [],
1533 )
1534 .unwrap();
1535 let err = new_person_with_learning(&conn, &[3, 4], "Bob", &context()).unwrap_err();
1536 assert_eq!(
1537 err.to_string(),
1538 "the request lists 2 face(s), cluster 7 has 3 unassigned face(s)"
1539 );
1540 }
1541
1542 #[test]
1543 fn a_named_face_is_rejected_with_a_reason() {
1544 let conn = seed();
1545 let err = new_person_with_learning(&conn, &[1], "Bob", &context()).unwrap_err();
1546 assert_eq!(err.to_string(), "face 1 is already named or confirmed");
1547 }
1548
1549 fn embedding(x: u16, y: u16) -> Vec<u8> {
1550 [x.to_le_bytes(), y.to_le_bytes()].concat()
1551 }
1552
1553 fn learning_seed() -> Connection {
1554 let conn = Connection::open_in_memory().unwrap();
1555 videre_core::face_db::create_faces_table(&conn).unwrap();
1556 conn.execute_batch(
1557 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1558 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice');",
1559 )
1560 .unwrap();
1561 let rows = [
1562 (1, "a1", embedding(0x3c00, 0), None, Some("alice"), 1),
1563 (2, "a2", embedding(0x3b9a, 0x3266), None, Some("alice"), 1),
1564 (3, "c1", embedding(0x3c00, 0), Some(7), None, 0),
1565 (4, "c2", embedding(0x3b9a, 0x3266), Some(7), None, 0),
1566 (5, "c3", embedding(0x3b33, 0x34cd), Some(7), None, 0),
1567 (6, "s1", embedding(0x3266, 0x3b9a), None, None, 0),
1568 (7, "d1", embedding(0x3c00, 0), Some(9), None, 0),
1569 (8, "d2", embedding(0, 0x3c00), Some(9), None, 0),
1570 ];
1571 for (id, hash, bytes, cluster, label, confirmed) in rows {
1572 conn.execute(
1573 "INSERT INTO file_hashes (hash, path) VALUES (?1, ?2)",
1574 rusqlite::params![hash, format!("/p/{hash}.jpg")],
1575 )
1576 .unwrap();
1577 conn.execute(
1578 "INSERT INTO faces
1579 (id, hash, bbox, embedding, cluster_id, person_label, confirmed,
1580 is_primary, det_score, blur)
1581 VALUES (?1, ?2, '0,0,112,112', ?3, ?4, ?5, ?6, 0, 0.95, 900.0)",
1582 rusqlite::params![id, hash, bytes, cluster, label, confirmed],
1583 )
1584 .unwrap();
1585 }
1586 conn
1587 }
1588
1589 fn off() -> TeachingContext {
1590 TeachingContext {
1591 record: false,
1592 ..context()
1593 }
1594 }
1595
1596 fn face_rows(conn: &Connection) -> Vec<(i64, Option<i64>, Option<String>, bool)> {
1597 let mut statement = conn
1598 .prepare("SELECT id, cluster_id, person_label, confirmed FROM faces ORDER BY id")
1599 .unwrap();
1600 let rows = statement
1601 .query_map([], |row| {
1602 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
1603 })
1604 .unwrap()
1605 .collect::<rusqlite::Result<Vec<_>>>()
1606 .unwrap();
1607 rows
1608 }
1609
1610 fn event_count(conn: &Connection) -> i64 {
1611 conn.query_row("SELECT count(*) FROM face_learning_events", [], |row| {
1612 row.get(0)
1613 })
1614 .unwrap_or(0)
1615 }
1616
1617 #[test]
1620 fn with_learning_off_mutations_write_the_same_faces_and_record_nothing() {
1621 type Step = fn(&Connection, &TeachingContext) -> Result<LearningAcknowledgement>;
1622 let steps: [(&str, Step); 4] = [
1623 ("assign", |c, x| assign_with_learning(c, &[6], "alice", x)),
1624 ("new person", |c, x| {
1625 new_person_with_learning(c, &[3, 4, 5], "Bob", x)
1626 }),
1627 ("remove face", |c, x| remove_face_with_learning(c, 7, x)),
1628 ("dissolve", |c, x| dissolve_cluster_with_learning(c, 9, x)),
1629 ];
1630 for (name, step) in steps {
1631 let on = learning_seed();
1632 let off_conn = learning_seed();
1633 step(&on, &context()).unwrap();
1634 let ack = step(&off_conn, &off()).unwrap();
1635 assert_eq!(face_rows(&on), face_rows(&off_conn), "{name}");
1636 assert_eq!(ack.event_ids, Vec::<i64>::new(), "{name}");
1637 assert_eq!(ack.message_key, "learning_off", "{name}");
1638 assert_eq!(ack.generation, 0, "{name}");
1639 assert_eq!(event_count(&off_conn), 0, "{name}");
1640 assert_eq!(learning_state(&off_conn).unwrap().generation, 0, "{name}");
1641 }
1642 }
1643
1644 #[test]
1645 fn with_learning_off_the_same_requests_are_rejected() {
1646 let conn = learning_seed();
1647 for (on, off) in [
1648 (
1649 new_person_with_learning(&conn, &[1], "Bob", &context()),
1650 new_person_with_learning(&conn, &[1], "Bob", &off()),
1651 ),
1652 (
1653 new_person_with_learning(&conn, &[3, 4], "Bob", &context()),
1654 new_person_with_learning(&conn, &[3, 4], "Bob", &off()),
1655 ),
1656 (
1657 assign_with_learning(&conn, &[6], "nobody", &context()),
1658 assign_with_learning(&conn, &[6], "nobody", &off()),
1659 ),
1660 (
1661 remove_face_with_learning(&conn, 6, &context()),
1662 remove_face_with_learning(&conn, 6, &off()),
1663 ),
1664 (
1665 dissolve_cluster_with_learning(&conn, 42, &context()),
1666 dissolve_cluster_with_learning(&conn, 42, &off()),
1667 ),
1668 ] {
1669 assert_eq!(on.unwrap_err().to_string(), off.unwrap_err().to_string());
1670 }
1671 assert_eq!(event_count(&conn), 0);
1672 }
1673
1674 #[test]
1675 fn learning_assignments_emit_expected_positive_evidence_once_per_action() {
1676 let conn = learning_seed();
1677
1678 let assigned = assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1679 assert_eq!(assigned.generation, 1);
1680 assert_eq!(assigned.event_ids.len(), 1);
1681
1682 let labeled = new_person_with_learning(&conn, &[3, 4, 5], "Bob", &context()).unwrap();
1683 assert_eq!(labeled.generation, 2);
1684 assert_eq!(labeled.event_ids.len(), 4);
1685
1686 let events = list_learning_events(&conn, 20, None).unwrap();
1687 assert_eq!(events.len(), 5);
1688 assert_eq!(
1689 events
1690 .iter()
1691 .filter(|event| event.action == LearningAction::LabelCluster
1692 && event.decision_kind == LearningDecisionKind::ClusterQuality
1693 && event.outcome == LearningOutcome::Positive)
1694 .count(),
1695 1
1696 );
1697 assert_eq!(
1698 events
1699 .iter()
1700 .filter(
1701 |event| event.decision_kind == LearningDecisionKind::Membership
1702 && event.outcome == LearningOutcome::Positive
1703 )
1704 .count(),
1705 4
1706 );
1707 assert!(events.iter().all(|event| {
1708 let json = event.features.to_canonical_json().unwrap();
1709 !json.contains("alice") && !json.contains("bob") && !json.contains("/p/")
1710 }));
1711
1712 let conn = learning_seed();
1713 let assigned_cluster =
1714 assign_with_learning(&conn, &[3, 4, 5], "alice", &context()).unwrap();
1715 assert_eq!(assigned_cluster.generation, 1);
1716 assert_eq!(assigned_cluster.event_ids.len(), 4);
1717 let events = list_learning_events(&conn, 20, None).unwrap();
1718 assert_eq!(
1719 events
1720 .iter()
1721 .filter(|event| event.action == LearningAction::AssignCluster
1722 && event.decision_kind == LearningDecisionKind::Membership)
1723 .count(),
1724 3
1725 );
1726 assert_eq!(
1727 events
1728 .iter()
1729 .filter(|event| event.action == LearningAction::AssignCluster
1730 && event.decision_kind == LearningDecisionKind::ClusterQuality)
1731 .count(),
1732 1
1733 );
1734 }
1735
1736 #[test]
1737 fn a_large_cluster_has_a_deterministic_per_action_membership_cap() {
1738 let conn = learning_seed();
1739 for id in 10..22 {
1740 let hash = format!("large-{id}");
1741 conn.execute(
1742 "INSERT INTO faces
1743 (id, hash, bbox, embedding, cluster_id, confirmed, is_primary,
1744 det_score, blur)
1745 VALUES (?1, ?2, '0,0,112,112', ?3, 42, 0, 0, 0.95, 900.0)",
1746 rusqlite::params![id, hash, embedding(0x3c00, (id as u16) + 0x2000)],
1747 )
1748 .unwrap();
1749 }
1750 let ids: Vec<_> = (10..22).collect();
1751 let acknowledgement =
1752 new_person_with_learning(&conn, &ids, "Large Family", &context()).unwrap();
1753 assert_eq!(
1754 acknowledgement.event_ids.len(),
1755 1 + MAX_MEMBERSHIP_EVENTS_PER_ACTION
1756 );
1757 assert_eq!(learning_state(&conn).unwrap().generation, 1);
1758
1759 let events = list_learning_events(&conn, 20, None).unwrap();
1760 assert_eq!(
1761 events
1762 .iter()
1763 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1764 .count(),
1765 MAX_MEMBERSHIP_EVENTS_PER_ACTION
1766 );
1767 assert!(events
1768 .iter()
1769 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1770 .all(|event| event.support_count as usize <= MAX_SUPPORT_FACES));
1771 }
1772
1773 #[test]
1774 fn learning_corrections_use_pre_action_state_without_pairwise_dissolve_labels() {
1775 let conn = learning_seed();
1776
1777 let removed_cluster = remove_face_with_learning(&conn, 3, &context()).unwrap();
1778 assert_eq!(removed_cluster.generation, 1);
1779 let removed_person = remove_face_with_learning(&conn, 2, &context()).unwrap();
1780 assert_eq!(removed_person.generation, 2);
1781 let dissolved = dissolve_cluster_with_learning(&conn, 9, &context()).unwrap();
1782 assert_eq!(dissolved.generation, 3);
1783
1784 let events = list_learning_events(&conn, 20, None).unwrap();
1785 assert_eq!(events.len(), 3);
1786 assert_eq!(
1787 events
1788 .iter()
1789 .filter(
1790 |event| event.decision_kind == LearningDecisionKind::Membership
1791 && event.outcome == LearningOutcome::Negative
1792 )
1793 .count(),
1794 2
1795 );
1796 let dissolve = events
1797 .iter()
1798 .find(|event| event.action == LearningAction::DissolveCluster)
1799 .unwrap();
1800 assert_eq!(dissolve.decision_kind, LearningDecisionKind::ClusterQuality);
1801 assert_eq!(dissolve.outcome, LearningOutcome::Negative);
1802 assert_eq!(dissolve.faces.len(), 2);
1803 }
1804
1805 #[test]
1806 fn unsupported_last_face_removals_still_apply_without_fabricated_evidence() {
1807 let conn = learning_seed();
1808 remove_face_with_learning(&conn, 1, &context()).unwrap();
1809 let last_person_face = remove_face_with_learning(&conn, 2, &context()).unwrap();
1810 assert!(last_person_face.event_ids.is_empty());
1811 assert_eq!(last_person_face.generation, 1);
1812 let person_state: (Option<String>, i64) = conn
1813 .query_row(
1814 "SELECT person_label, confirmed FROM faces WHERE id = 2",
1815 [],
1816 |row| Ok((row.get(0)?, row.get(1)?)),
1817 )
1818 .unwrap();
1819 assert_eq!(person_state, (None, 0));
1820
1821 remove_face_with_learning(&conn, 3, &context()).unwrap();
1822 remove_face_with_learning(&conn, 4, &context()).unwrap();
1823 let last_cluster_face = remove_face_with_learning(&conn, 5, &context()).unwrap();
1824 assert!(last_cluster_face.event_ids.is_empty());
1825 assert_eq!(last_cluster_face.generation, 3);
1826 let cluster_id: Option<i64> = conn
1827 .query_row("SELECT cluster_id FROM faces WHERE id = 5", [], |row| {
1828 row.get(0)
1829 })
1830 .unwrap();
1831 assert_eq!(cluster_id, None);
1832 }
1833
1834 #[test]
1838 fn following_the_waiting_ask_starts_a_new_run_and_one_face_does_not() {
1839 let conn = learning_seed();
1840 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1841 videre_core::face_learning::mark_training_started(&conn).unwrap();
1842 let ask = videre_core::face_learning::TrainingError::OneSidedFold {
1843 decision_kind: videre_core::face_learning::LearningDecisionKind::Membership,
1844 lacking_negatives: true,
1845 }
1846 .feedback_needed(&videre_core::face_learning::TrainingConfig::default())
1847 .unwrap();
1848 assert_eq!(ask, "name 1 more person from a group of two or more faces");
1849 videre_core::face_learning::mark_training_waiting(&conn, 1, &ask).unwrap();
1850
1851 let single = new_person_with_learning(&conn, &[6], "Çağla", &context()).unwrap();
1852 assert!(single.event_ids.is_empty());
1853 let status = face_learning_status(&conn).unwrap();
1854 assert_eq!(
1855 (status.generation, status.status.as_str()),
1856 (1, "waiting"),
1857 "one face records nothing, so nothing new is trained"
1858 );
1859 assert_eq!(status.feedback_needed.as_deref(), Some(ask.as_str()));
1860
1861 let group = new_person_with_learning(&conn, &[3, 4, 5], "Özgür", &context()).unwrap();
1862 assert!(!group.event_ids.is_empty());
1863 let status = face_learning_status(&conn).unwrap();
1864 assert_eq!(
1865 (status.generation, status.status.as_str()),
1866 (2, "stale"),
1867 "a named group is new evidence, so the worker trains again"
1868 );
1869 assert_eq!(status.feedback_needed, None);
1870 assert_eq!(
1871 status.last_error, None,
1872 "the ask stored for the waiting run never reads as an error"
1873 );
1874 }
1875
1876 #[test]
1880 fn a_stale_state_never_reports_the_waiting_ask_as_an_error() {
1881 let conn = learning_seed();
1882 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1883 videre_core::face_learning::mark_training_started(&conn).unwrap();
1884 videre_core::face_learning::mark_training_waiting(
1885 &conn,
1886 1,
1887 "dissolve 2 more wrong clusters",
1888 )
1889 .unwrap();
1890 conn.execute(
1891 "UPDATE face_learning_state SET generation = generation + 1, status = 'stale'",
1892 [],
1893 )
1894 .unwrap();
1895 let status = face_learning_status(&conn).unwrap();
1896 assert_eq!(status.status, "stale");
1897 assert_eq!(status.last_error, None);
1898 assert_eq!(status.feedback_needed, None);
1899 }
1900
1901 fn insert_profile(conn: &Connection, stage: &str, status: &str, gates: &str) {
1902 videre_core::face_learning::ensure_profile_table(conn).unwrap();
1903 conn.execute(
1904 "INSERT INTO face_learning_profiles (
1905 artifact_version, embedding_model_id, feature_schema_version, model_kind,
1906 parameters, training_evidence_json, validation_report_json, stage, status,
1907 promotion_result_json, created_at
1908 ) VALUES (1, 'm', 1, 'logistic', X'00', '{}', '{}', ?1, ?2, ?3, 'now')",
1909 rusqlite::params![stage, status, gates],
1910 )
1911 .unwrap();
1912 }
1913
1914 #[test]
1915 fn the_summary_says_learning_is_not_used_before_any_profile() {
1916 let conn = learning_seed();
1917 let status = face_learning_status(&conn).unwrap();
1918 assert_eq!(status.active_profile, None);
1919 assert_eq!(
1920 status.summary,
1921 "Learning: not used yet; naming people teaches it."
1922 );
1923 }
1924
1925 #[test]
1926 fn the_summary_names_rejected_candidates_and_the_gate_they_missed() {
1927 let conn = learning_seed();
1928 let gates = r#"[{"dataset_key":"cluster_quality-fold-2","gate":"suggestion_precision","observed":0.8333,"required":0.85}]"#;
1929 insert_profile(&conn, "suggestion", "rejected", gates);
1930 insert_profile(&conn, "suggestion", "rejected", gates);
1931 let status = face_learning_status(&conn).unwrap();
1932 assert_eq!(
1933 status.summary,
1934 "Learning: not used yet; 2 trained candidate(s) did not pass the quality checks \
1935 (suggestion precision 0.83, needs 0.85). More confirmed names help."
1936 );
1937 }
1938
1939 #[test]
1940 fn the_summary_names_the_active_profile() {
1941 let conn = learning_seed();
1942 insert_profile(&conn, "suggestion", "active", "[]");
1943 let status = face_learning_status(&conn).unwrap();
1944 let active = status.active_profile.expect("an active profile");
1945 assert_eq!(active.stage, "suggestion");
1946 assert_eq!(
1947 status.summary,
1948 format!(
1949 "Learning: profile {} suggests names; grouping uses the settings above.",
1950 active.profile_id
1951 )
1952 );
1953 }
1954
1955 #[test]
1956 fn new_person_collision_uses_existing_person_support() {
1957 let conn = learning_seed();
1958 let acknowledgement =
1959 new_person_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1960 assert_eq!(acknowledgement.generation, 1);
1961 assert_eq!(acknowledgement.event_ids.len(), 1);
1962 let events = list_learning_events(&conn, 10, None).unwrap();
1963 assert_eq!(events[0].action, LearningAction::AssignFace);
1964 assert_eq!(events[0].target_identity.as_deref(), Some("alice"));
1965 assert_eq!(events[0].support_count, 2);
1966 }
1967
1968 #[test]
1969 fn assigning_to_a_face_less_person_keeps_only_supported_evidence() {
1970 let conn = learning_seed();
1971 conn.execute(
1972 "UPDATE faces
1973 SET person_label = NULL, confirmed = 0
1974 WHERE person_label = 'alice'",
1975 [],
1976 )
1977 .unwrap();
1978
1979 let singleton = assign_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1980 assert!(singleton.event_ids.is_empty());
1981 assert_eq!(singleton.generation, 0);
1982 assert_eq!(singleton.message_key, "face_named_without_comparison");
1983 let assigned: (Option<String>, i64) = conn
1984 .query_row(
1985 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1986 [],
1987 |row| Ok((row.get(0)?, row.get(1)?)),
1988 )
1989 .unwrap();
1990 assert_eq!(assigned, (Some("alice".to_owned()), 1));
1991 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1992
1993 let conn = learning_seed();
1994 conn.execute(
1995 "UPDATE faces
1996 SET person_label = NULL, confirmed = 0
1997 WHERE person_label = 'alice'",
1998 [],
1999 )
2000 .unwrap();
2001 let cluster = new_person_with_learning(&conn, &[3, 4, 5], "Alice", &context()).unwrap();
2002 assert_eq!(cluster.event_ids.len(), 1);
2003 assert_eq!(cluster.generation, 1);
2004 let events = list_learning_events(&conn, 10, None).unwrap();
2005 assert_eq!(events.len(), 1);
2006 assert_eq!(events[0].action, LearningAction::AssignCluster);
2007 assert_eq!(
2008 events[0].decision_kind,
2009 LearningDecisionKind::ClusterQuality
2010 );
2011 }
2012
2013 #[test]
2014 fn event_insert_failure_rolls_back_the_visible_assignment_and_generation() {
2015 let conn = learning_seed();
2016 conn.execute_batch(
2017 "CREATE TRIGGER reject_learning_event
2018 BEFORE INSERT ON face_learning_events
2019 BEGIN SELECT RAISE(ABORT, 'test rejection'); END;",
2020 )
2021 .unwrap();
2022
2023 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
2024 let state: (Option<String>, i64) = conn
2025 .query_row(
2026 "SELECT person_label, confirmed FROM faces WHERE id = 6",
2027 [],
2028 |row| Ok((row.get(0)?, row.get(1)?)),
2029 )
2030 .unwrap();
2031 assert_eq!(state, (None, 0));
2032 assert_eq!(learning_state(&conn).unwrap().generation, 0);
2033 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
2034 }
2035
2036 #[test]
2037 fn commit_failure_rolls_back_faces_events_and_generation() {
2038 let conn = learning_seed();
2039 conn.execute_batch(
2040 "PRAGMA foreign_keys = ON;
2041 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
2042 CREATE TABLE commit_guard_child (
2043 event_id INTEGER PRIMARY KEY,
2044 parent_id INTEGER NOT NULL,
2045 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
2046 DEFERRABLE INITIALLY DEFERRED
2047 );
2048 CREATE TRIGGER fail_learning_commit
2049 AFTER INSERT ON face_learning_events
2050 BEGIN
2051 INSERT INTO commit_guard_child (event_id, parent_id)
2052 VALUES (NEW.id, 999);
2053 END;",
2054 )
2055 .unwrap();
2056
2057 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
2058 let state: (Option<String>, i64) = conn
2059 .query_row(
2060 "SELECT person_label, confirmed FROM faces WHERE id = 6",
2061 [],
2062 |row| Ok((row.get(0)?, row.get(1)?)),
2063 )
2064 .unwrap();
2065 assert_eq!(state, (None, 0));
2066 assert_eq!(learning_state(&conn).unwrap().generation, 0);
2067 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
2068 }
2069
2070 #[test]
2071 fn malformed_or_mixed_prestate_rolls_back_without_learning() {
2072 let conn = learning_seed();
2073 conn.execute("UPDATE faces SET embedding = X'0000' WHERE id = 6", [])
2074 .unwrap();
2075 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
2076 assert!(new_person_with_learning(&conn, &[3, 7], "Bob", &context()).is_err());
2077 assert!(new_person_with_learning(&conn, &[1], "Bob", &context()).is_err());
2078 assert!(assign_with_learning(&conn, &[999], "alice", &context()).is_err());
2079 assert_eq!(learning_state(&conn).unwrap().generation, 0);
2080 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
2081 }
2082
2083 #[test]
2084 fn deleting_a_person_invalidates_identity_evidence_without_a_negative_event() {
2085 let conn = learning_seed();
2086 assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
2087 let acknowledgement = delete_person_with_learning(&conn, "alice")
2088 .unwrap()
2089 .unwrap();
2090 assert_eq!(acknowledgement.generation, 2);
2091 assert!(acknowledgement.event_ids.is_empty());
2092
2093 let events = list_learning_events(&conn, 20, None).unwrap();
2094 assert_eq!(events.len(), 1);
2095 assert!(!events[0].eligible);
2096 assert_eq!(
2097 events[0].invalidation_reason,
2098 Some(videre_core::face_learning::InvalidationReason::PersonRemoved)
2099 );
2100 assert!(delete_person_with_learning(&conn, "alice")
2101 .unwrap()
2102 .is_none());
2103 assert_eq!(learning_state(&conn).unwrap().generation, 2);
2104 }
2105
2106 #[test]
2107 fn deleting_a_person_without_learning_evidence_keeps_generation_current() {
2108 let conn = learning_seed();
2109 assert_eq!(learning_state(&conn).unwrap().generation, 0);
2110
2111 let acknowledgement = delete_person_with_learning(&conn, "alice")
2112 .unwrap()
2113 .unwrap();
2114
2115 assert_eq!(acknowledgement.generation, 0);
2116 assert!(acknowledgement.event_ids.is_empty());
2117 assert_eq!(learning_state(&conn).unwrap().generation, 0);
2118 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
2119 }
2120 }
2121
2122 #[test]
2123 fn the_list_comes_back_in_the_same_order_every_time() {
2124 let conn = seed();
2131 conn.execute_batch(
2135 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
2138 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
2139 INSERT INTO people (name, full_name) VALUES ('bob','Bob');
2140 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
2141 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
2142 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
2143 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
2144 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
2145 (10,'h10','0,0,9,9',X'0000',NULL,'bob',1,0);",
2146 )
2147 .unwrap();
2148
2149 let a = faces_list(&conn).unwrap();
2152 let b = faces_list(&conn).unwrap();
2153
2154 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
2155 let names =
2156 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
2157 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
2158 assert_eq!(
2159 ids(&a),
2160 ids(&b),
2161 "cluster order must not change between calls"
2162 );
2163 assert_eq!(
2164 names(&a),
2165 names(&b),
2166 "people order must not change between calls"
2167 );
2168
2169 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
2172 let mut want = sizes.clone();
2173 want.sort_unstable_by(|x, y| y.cmp(x));
2174 assert_eq!(
2175 sizes, want,
2176 "clusters must be ordered largest first, got {sizes:?}"
2177 );
2178 }
2179
2180 #[test]
2181 fn faces_list_splits_people_clusters_singletons() {
2182 let conn = seed();
2183 let d = faces_list(&conn).unwrap();
2184 assert_eq!(d.people.len(), 1);
2185 assert_eq!(d.people[0].label, "alice");
2187 assert_eq!(d.people[0].full_name, "Alice");
2188 assert_eq!(
2189 d.people[0].representative_id, 1,
2190 "primary face is representative"
2191 );
2192 assert_eq!(d.clusters.len(), 1);
2193 assert_eq!(d.clusters[0].cluster_id, 7);
2194 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
2195 assert_eq!(d.singletons.len(), 1);
2196 assert_eq!(d.singletons[0].face_id, 5);
2197 }
2198
2199 #[test]
2200 fn person_detail_marks_primary() {
2201 let conn = seed();
2202 let p = person_detail(&conn, "Alice").unwrap();
2203 assert_eq!(p.faces.len(), 2);
2204 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
2205 assert!(!p.faces[1].is_primary);
2206 }
2207
2208 fn seed_with_a_second_path_for(hash: &str) -> Connection {
2210 let conn = seed();
2211 conn.execute_batch(
2212 "ALTER TABLE file_hashes RENAME TO file_hashes_old;
2213 CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT);
2214 INSERT INTO file_hashes (path, hash) SELECT path, hash FROM file_hashes_old;
2215 DROP TABLE file_hashes_old;",
2216 )
2217 .unwrap();
2218 conn.execute(
2219 "INSERT INTO file_hashes (path, hash) VALUES (?1, ?2)",
2220 rusqlite::params![format!("/copy/{hash}.jpg"), hash],
2221 )
2222 .unwrap();
2223 conn
2224 }
2225
2226 #[test]
2227 fn detail_pages_list_a_face_once_when_its_photo_has_two_paths() {
2228 let conn = seed_with_a_second_path_for("h3");
2229 let c = cluster_detail(&conn, 7).unwrap();
2230 assert_eq!(
2231 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
2232 vec![3, 4]
2233 );
2234 let conn = seed_with_a_second_path_for("h1");
2235 let p = person_detail(&conn, "Alice").unwrap();
2236 assert_eq!(
2237 p.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
2238 vec![1, 2]
2239 );
2240 assert!(p.faces[0].is_primary);
2241 }
2242
2243 #[test]
2244 fn cluster_detail_lists_faces() {
2245 let conn = seed();
2246 let c = cluster_detail(&conn, 7).unwrap();
2247 assert_eq!(c.cluster_id, 7);
2248 assert_eq!(
2249 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
2250 vec![3, 4]
2251 );
2252 }
2253
2254 #[test]
2255 fn assign_labels_and_confirms() {
2256 let conn = seed();
2257 assign(&conn, &[3, 4], "Bob").unwrap();
2258 let p = person_detail(&conn, "Bob").unwrap();
2259 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
2260 }
2261
2262 #[test]
2263 fn assign_rejects_empty_label() {
2264 let conn = seed();
2265 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
2266 }
2267
2268 #[test]
2269 fn remove_face_unassigns_everything() {
2270 let conn = seed();
2271 remove_face(&conn, 1).unwrap();
2272 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
2273 .query_row(
2274 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
2275 [],
2276 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
2277 )
2278 .unwrap();
2279 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
2280 }
2281
2282 #[test]
2283 fn dissolve_cluster_nulls_cluster_id() {
2284 let conn = seed();
2285 dissolve_cluster(&conn, 7).unwrap();
2286 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
2287 assert_eq!(
2288 faces_list(&conn).unwrap().singletons.len(),
2289 3,
2290 "3,4 join 5 as singletons"
2291 );
2292 }
2293
2294 #[test]
2295 fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
2296 let conn = seed();
2299 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
2300 let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
2301 assert!(before > 0);
2302 delete_person(&conn, "ghost").unwrap();
2303 assert_eq!(
2304 videre_core::face_db::recluster_watermark(&conn).unwrap(),
2305 before,
2306 "a no-op delete must not reopen the gated regroup"
2307 );
2308 }
2309
2310 #[test]
2311 fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
2312 let conn = seed();
2319 assign(&conn, &[1, 2], "Alice").unwrap();
2320 assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
2321 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
2324 assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
2325
2326 delete_person(&conn, "Alice").unwrap();
2327 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
2328 assert_eq!(
2329 videre_core::face_db::recluster_watermark(&conn).unwrap(),
2330 0,
2331 "deleting a person must reopen the gated regroup for their faces"
2332 );
2333 let rows: Vec<(Option<i64>, Option<String>, i64)> = {
2334 let mut s = conn
2335 .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
2336 .unwrap();
2337 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
2338 .unwrap()
2339 .collect::<rusqlite::Result<_>>()
2340 .unwrap()
2341 };
2342 assert!(
2343 rows.iter()
2344 .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
2345 "every face returns to the unassigned pool: {rows:?}"
2346 );
2347 }
2348
2349 #[test]
2350 fn set_primary_is_exclusive_per_person() {
2351 let conn = seed();
2352 set_primary(&conn, 2, "Alice").unwrap();
2353 let primaries: Vec<i64> = {
2354 let mut s = conn
2355 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
2356 .unwrap();
2357 s.query_map([], |r| r.get(0))
2358 .unwrap()
2359 .collect::<rusqlite::Result<_>>()
2360 .unwrap()
2361 };
2362 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
2363 }
2364
2365 #[test]
2366 fn renaming_only_the_spelling_keeps_the_identity() {
2367 let conn = seed();
2370 set_full_name(&conn, "alice", "Alice Smith").unwrap();
2371 let (name, full): (String, String) = conn
2372 .query_row("SELECT name, full_name FROM people", [], |r| {
2373 Ok((r.get(0)?, r.get(1)?))
2374 })
2375 .unwrap();
2376 assert_eq!(name, "alice", "identity is unchanged");
2377 assert_eq!(full, "Alice Smith", "only the display name moved");
2378 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
2379 }
2380
2381 #[test]
2388 fn assign_a_missing_face_is_not_found() {
2389 let conn = seed();
2390 assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
2391 }
2392
2393 #[test]
2394 fn assign_is_atomic_when_one_face_is_missing() {
2395 let conn = seed();
2399 assert!(matches!(
2400 assign(&conn, &[3, 999], "Bob"),
2401 Err(Error::NotFound)
2402 ));
2403 let (label, confirmed): (Option<String>, i64) = conn
2404 .query_row(
2405 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2406 [],
2407 |r| Ok((r.get(0)?, r.get(1)?)),
2408 )
2409 .unwrap();
2410 assert_eq!(label, None, "face 3 must not have been labelled");
2411 assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
2412 let bob: i64 = conn
2413 .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
2414 r.get(0)
2415 })
2416 .unwrap();
2417 assert_eq!(
2418 bob, 0,
2419 "no person may be created when the assign rolls back"
2420 );
2421 }
2422
2423 #[test]
2424 fn assign_commit_failure_rolls_back_and_closes_the_transaction() {
2425 let conn = seed();
2426 conn.execute_batch(
2427 "PRAGMA foreign_keys = ON;
2428 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
2429 CREATE TABLE commit_guard_child (
2430 face_id INTEGER PRIMARY KEY,
2431 parent_id INTEGER NOT NULL,
2432 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
2433 DEFERRABLE INITIALLY DEFERRED
2434 );
2435 CREATE TRIGGER fail_assign_commit
2436 AFTER UPDATE ON faces
2437 WHEN NEW.id = 3
2438 BEGIN
2439 INSERT INTO commit_guard_child (face_id, parent_id)
2440 VALUES (NEW.id, 999);
2441 END;",
2442 )
2443 .unwrap();
2444
2445 assert!(assign(&conn, &[3], "Bob").is_err());
2446 assert!(conn.is_autocommit());
2447 let state: (Option<String>, i64) = conn
2448 .query_row(
2449 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2450 [],
2451 |row| Ok((row.get(0)?, row.get(1)?)),
2452 )
2453 .unwrap();
2454 assert_eq!(state, (None, 0));
2455 let bob: i64 = conn
2456 .query_row(
2457 "SELECT COUNT(*) FROM people WHERE name = 'bob'",
2458 [],
2459 |row| row.get(0),
2460 )
2461 .unwrap();
2462 assert_eq!(bob, 0);
2463 }
2464
2465 #[test]
2466 fn assign_rejects_empty_face_ids() {
2467 let conn = seed();
2470 assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
2471 }
2472
2473 #[test]
2474 fn remove_face_missing_is_not_found() {
2475 let conn = seed();
2476 assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
2477 }
2478
2479 #[test]
2480 fn dissolve_cluster_missing_is_not_found() {
2481 let conn = seed();
2482 assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
2483 }
2484
2485 #[test]
2486 fn set_primary_missing_face_is_not_found() {
2487 let conn = seed();
2488 assert!(matches!(
2489 set_primary(&conn, 999, "Alice"),
2490 Err(Error::NotFound)
2491 ));
2492 }
2493
2494 #[test]
2495 fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
2496 let conn = seed();
2500 assert!(matches!(
2501 set_primary(&conn, 5, "Alice"),
2502 Err(Error::NotFound)
2503 ));
2504 let primary: i64 = conn
2505 .query_row(
2506 "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
2507 [],
2508 |r| r.get(0),
2509 )
2510 .unwrap();
2511 assert_eq!(
2512 primary, 1,
2513 "the original primary must be restored on rollback"
2514 );
2515 }
2516
2517 #[test]
2518 fn delete_person_missing_is_idempotent_success() {
2519 let conn = seed();
2525 assert!(delete_person(&conn, "Nobody").is_ok());
2526 }
2527}
2528
2529#[cfg(test)]
2530mod identity_tests {
2531 use super::tests::seed;
2532 use super::*;
2533
2534 fn people(conn: &Connection) -> Vec<(String, String)> {
2535 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
2536 .unwrap()
2537 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2538 .unwrap()
2539 .collect::<rusqlite::Result<_>>()
2540 .unwrap()
2541 }
2542
2543 #[test]
2544 fn assign_stores_the_identity_and_records_the_display_name() {
2545 let conn = seed();
2546 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
2547
2548 let label: String = conn
2549 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
2550 r.get(0)
2551 })
2552 .unwrap();
2553 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
2554 assert!(
2555 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
2556 "and the spelling is kept for display"
2557 );
2558 }
2559
2560 #[test]
2561 fn assigning_an_existing_name_in_another_case_joins_that_person() {
2562 let conn = seed();
2565 assign(&conn, &[3], "ALICE").unwrap();
2566 assert_eq!(people(&conn).len(), 1, "still one person, not two");
2567 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
2568 assert_eq!(
2569 people(&conn)[0].1,
2570 "Alice",
2571 "the existing spelling is not overwritten by the new casing"
2572 );
2573 }
2574
2575 #[test]
2576 fn assign_rejects_a_name_with_no_usable_identity() {
2577 let conn = seed();
2580 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
2581 }
2582
2583 #[test]
2584 fn person_detail_resolves_every_form_of_the_name() {
2585 let conn = seed();
2586 for form in ["alice", "Alice", "ALICE", " alice "] {
2587 assert_eq!(
2588 person_detail(&conn, form).unwrap().faces.len(),
2589 2,
2590 "form {form:?}"
2591 );
2592 }
2593 }
2594
2595 #[test]
2596 fn person_detail_reports_the_display_name() {
2597 let d = person_detail(&seed(), "alice").unwrap();
2598 assert_eq!(d.label, "alice");
2599 assert_eq!(d.full_name, "Alice");
2600 }
2601
2602 #[test]
2603 fn person_detail_falls_back_when_there_is_no_people_row() {
2604 let conn = seed();
2608 conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap();
2609 conn.execute(
2610 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
2611 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
2612 [],
2613 )
2614 .unwrap();
2615 conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
2616 let d = person_detail(&conn, "orphan").unwrap();
2617 assert_eq!(d.full_name, "orphan", "falls back to the identity");
2618 }
2619
2620 #[test]
2621 fn set_full_name_changes_only_the_display_name() {
2622 let conn = seed();
2623 set_full_name(&conn, "alice", "Alice Smith").unwrap();
2624 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
2625 assert_eq!(
2626 person_detail(&conn, "alice").unwrap().faces.len(),
2627 2,
2628 "no face was touched"
2629 );
2630 }
2631
2632 #[test]
2633 fn set_full_name_accepts_any_form_of_the_identity() {
2634 let conn = seed();
2635 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
2636 assert_eq!(people(&conn)[0].1, "Alice Smith");
2637 }
2638
2639 #[test]
2640 fn set_full_name_on_a_missing_person_is_not_found() {
2641 assert!(matches!(
2642 set_full_name(&seed(), "nobody", "Someone"),
2643 Err(Error::NotFound)
2644 ));
2645 }
2646
2647 #[test]
2648 fn set_full_name_rejects_an_empty_display_name() {
2649 assert!(matches!(
2651 set_full_name(&seed(), "alice", " "),
2652 Err(Error::Invalid)
2653 ));
2654 }
2655
2656 #[test]
2657 fn delete_person_accepts_any_form_of_the_name() {
2658 let conn = seed();
2659 delete_person(&conn, "Alice").unwrap();
2660 let left: i64 = conn
2661 .query_row(
2662 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
2663 [],
2664 |r| r.get(0),
2665 )
2666 .unwrap();
2667 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
2668 }
2669
2670 #[test]
2671 fn set_primary_accepts_any_form_of_the_name() {
2672 let conn = seed();
2673 set_primary(&conn, 2, "ALICE").unwrap();
2674 let primary: i64 = conn
2675 .query_row(
2676 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
2677 [],
2678 |r| r.get(0),
2679 )
2680 .unwrap();
2681 assert_eq!(primary, 2);
2682 }
2683}
2684
2685#[cfg(test)]
2686mod never_run_tests {
2687 use super::*;
2688
2689 #[test]
2701 fn a_library_that_never_ran_detection_is_empty_not_an_error() {
2702 let conn = Connection::open_in_memory().unwrap();
2703 conn.execute_batch(
2704 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
2705 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
2706 )
2707 .unwrap();
2708
2709 let data = faces_list(&conn).expect("a library with no faces table is not an error");
2710 assert!(data.people.is_empty());
2711 assert!(data.clusters.is_empty());
2712 assert!(data.singletons.is_empty());
2713 }
2714
2715 mod question_fixture {
2718 use super::*;
2719 use videre_core::face_learning::{
2720 ensure_question_tables, replace_pending_questions, select_questions, LogisticModel,
2721 LogisticScorer, ModelBundle, QuestionSelectionConfig, MEMBERSHIP_FEATURE_NAMES,
2722 MODEL_ARTIFACT_VERSION,
2723 };
2724
2725 pub fn embedding_blob(x: f32, y: f32) -> Vec<u8> {
2726 let mut bytes = Vec::with_capacity(4);
2727 bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
2728 bytes.extend_from_slice(&half::f16::from_f32(y).to_le_bytes());
2729 bytes
2730 }
2731
2732 fn logistic_bundle() -> ModelBundle {
2733 let names: Vec<String> = MEMBERSHIP_FEATURE_NAMES
2734 .iter()
2735 .map(|name| name.to_string())
2736 .collect();
2737 let means: Vec<f64> = names
2738 .iter()
2739 .map(|name| if name == "similarity_mean" { 1.0 } else { 0.0 })
2740 .collect();
2741 let scales: Vec<f64> = names
2742 .iter()
2743 .map(|name| if name == "similarity_mean" { 0.5 } else { 1.0 })
2744 .collect();
2745 let weights: Vec<f64> = names
2746 .iter()
2747 .map(|name| if name == "similarity_mean" { 2.0 } else { 0.0 })
2748 .collect();
2749 let scorer = LogisticScorer {
2750 model: LogisticModel {
2751 feature_names: names,
2752 means,
2753 scales,
2754 intercept: 0.0,
2755 weights,
2756 l2: 1.0,
2757 positive_class_weight: 1.0,
2758 },
2759 calibration: videre_core::face_learning::CalibrationModel {
2760 intercept: 0.0,
2761 slope: 1.0,
2762 },
2763 threshold: 0.5,
2764 };
2765 ModelBundle::Logistic {
2766 artifact_version: MODEL_ARTIFACT_VERSION,
2767 embedding_model_id: "arcface/test".into(),
2768 feature_schema_version: 1,
2769 membership: scorer.clone(),
2770 cluster_quality: scorer,
2771 }
2772 }
2773
2774 pub fn library() -> (Connection, i64, i64) {
2780 let conn = Connection::open_in_memory().unwrap();
2781 conn.execute_batch(
2782 "PRAGMA foreign_keys = ON;
2783 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
2784 CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
2785 bbox TEXT NOT NULL, landmark TEXT, embedding BLOB NOT NULL,
2786 cluster_id INTEGER,
2787 person_label TEXT REFERENCES people(name) ON DELETE RESTRICT ON UPDATE RESTRICT,
2788 confirmed INTEGER DEFAULT 0,
2789 is_primary INTEGER DEFAULT 0, det_score REAL, blur REAL, oriented INTEGER);",
2790 )
2791 .unwrap();
2792 videre_core::face_learning::ensure_learning_tables(&conn).unwrap();
2793 videre_core::face_learning::ensure_profile_table(&conn).unwrap();
2794 ensure_question_tables(&conn).unwrap();
2795
2796 for (id, cluster) in [(10, Some(1)), (11, Some(1)), (12, None), (13, None)] {
2797 conn.execute(
2798 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2799 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, 0.9, 600.0)",
2800 rusqlite::params![id, embedding_blob(1.0, 0.0), cluster],
2801 )
2802 .unwrap();
2803 }
2804 assign(&conn, &[12, 13], "Alice").unwrap();
2805
2806 let evidence =
2807 serde_json::to_string(&videre_core::face_learning::TrainingEvidenceCounts {
2808 positive_pairs: 20,
2809 negative_pairs: 20,
2810 explicit_negative_pairs: 0,
2811 })
2812 .unwrap();
2813 let report = serde_json::to_string(&videre_core::face_learning::ValidationReport {
2814 protocol_version: 1,
2815 evidence_schema_version: 1,
2816 feature_schema_version: 1,
2817 datasets: Vec::new(),
2818 })
2819 .unwrap();
2820 conn.execute(
2821 "INSERT INTO face_learning_profiles (
2822 artifact_version, embedding_model_id, feature_schema_version, model_kind,
2823 parameters, training_evidence_json, validation_report_json, stage, status
2824 ) VALUES (1, 'arcface/test', 1, 'logistic', ?1, ?2, ?3, 'suggestion', 'active')",
2825 rusqlite::params![
2826 serde_json::to_vec(&logistic_bundle()).unwrap(),
2827 evidence,
2828 report
2829 ],
2830 )
2831 .unwrap();
2832 let profile_id = conn.last_insert_rowid();
2833
2834 let candidates = select_questions(&conn, &QuestionSelectionConfig::default()).unwrap();
2835 assert_eq!(candidates.len(), 1, "fixture must produce one question");
2836 let stored = replace_pending_questions(&conn, &candidates).unwrap();
2837 assert_eq!(stored.len(), 1);
2838 (conn, stored[0].id, profile_id)
2839 }
2840
2841 pub fn stub_evidence() -> videre_core::face_learning::DecisionEvidence {
2842 use videre_core::face_learning::{
2843 Calibration, DecisionKind, DecisionOutcome, DecisionTarget, FeatureContribution,
2844 ValidationSummary, EVIDENCE_SCHEMA_VERSION, FEATURE_SCHEMA_VERSION,
2845 };
2846 let evidence = videre_core::face_learning::DecisionEvidence {
2847 schema_version: EVIDENCE_SCHEMA_VERSION,
2848 profile_id: 1,
2849 feature_schema_version: FEATURE_SCHEMA_VERSION,
2850 decision_kind: DecisionKind::Membership,
2851 outcome: DecisionOutcome::Allowed,
2852 subject_face_ids: vec![10],
2853 target: DecisionTarget::Person("alice".into()),
2854 intercept: 0.0,
2855 raw_logit: 0.0,
2856 calibration: Calibration {
2857 intercept: 0.0,
2858 slope: 1.0,
2859 },
2860 calibrated_confidence: 0.5,
2861 threshold: 0.5,
2862 margin: 0.0,
2863 features: vec![FeatureContribution {
2864 name: "similarity_mean".into(),
2865 value: 1.0,
2866 contribution: 0.0,
2867 }],
2868 support_face_ids: vec![12, 13],
2869 rule_vetoes: Vec::new(),
2870 validation: ValidationSummary {
2871 protocol_version: 1,
2872 datasets: 1,
2873 pair_precision: None,
2874 pair_recall: None,
2875 suggestion_precision: None,
2876 suggestion_coverage: None,
2877 },
2878 };
2879 evidence.validate().unwrap();
2880 evidence
2881 }
2882
2883 pub fn context(profile_id: i64) -> TeachingContext {
2884 TeachingContext {
2885 embedding_model_id: "arcface/test".into(),
2886 active_profile_id: Some(profile_id),
2887 record: true,
2888 }
2889 }
2890 }
2891
2892 use question_fixture as qf;
2893
2894 #[test]
2895 fn deleting_a_person_supersedes_questions_and_advances_once() {
2896 let (conn, _question_id, _profile_id) = qf::library();
2897 let second = videre_core::face_learning::StoredQuestion {
2899 id: 999,
2900 status: videre_core::face_learning::QuestionStatus::Pending,
2901 subject_face_ids: vec![10],
2902 support_face_ids: vec![12, 13],
2903 target_identity: "alice".into(),
2904 target_display: "Alice".into(),
2905 profile_id: 1,
2906 model_kind: "logistic".into(),
2907 representative_face_id: 10,
2908 cluster_id: 1,
2909 evidence_revision: "another-revision".into(),
2910 evidence: qf::stub_evidence(),
2911 created_at: "2026-01-01 00:00:00".into(),
2912 decided_at: None,
2913 };
2914 let _ = second;
2915 delete_person_with_learning(&conn, "Alice").unwrap();
2916 let superseded: i64 = conn
2917 .query_row(
2918 "SELECT count(*) FROM face_learning_questions WHERE status = 'superseded'",
2919 [],
2920 |row| row.get(0),
2921 )
2922 .unwrap();
2923 assert_eq!(superseded, 1, "the pending question must be superseded");
2924 let state = learning_state(&conn).unwrap();
2925 assert_eq!(state.generation, 1, "exactly one generation advance");
2926 let invalidated: i64 = conn
2927 .query_row(
2928 "SELECT count(*) FROM face_learning_events WHERE eligible = 0",
2929 [],
2930 |row| row.get(0),
2931 )
2932 .unwrap();
2933 assert_eq!(invalidated, 0, "no events existed to invalidate");
2934 }
2935
2936 #[test]
2937 fn the_journal_reports_availability_without_rewriting_history() {
2938 let (conn, _question_id, profile_id) = qf::library();
2939 assign_with_learning(&conn, &[10, 11], "Alice", &qf::context(profile_id)).unwrap();
2941 let subject_event_id = face_learning_events(&conn, 50, None, Some("arcface/test"))
2942 .unwrap()
2943 .iter()
2944 .find(|proof| proof.event.faces.iter().any(|face| face.face_id == 10))
2945 .map(|proof| proof.event.id)
2946 .unwrap();
2947 conn.execute("DELETE FROM faces WHERE id = 10", []).unwrap();
2950
2951 let proofs = face_learning_events(&conn, 50, None, Some("arcface/test")).unwrap();
2952 let proof = proofs
2953 .iter()
2954 .find(|proof| proof.event.id == subject_event_id)
2955 .unwrap();
2956 assert!(!proof.source_available, "the subject face is gone");
2957 assert!(!proof.incompatible, "same model and schema stay usable");
2958 assert!(proof.event.eligible, "missing provenance stays eligible");
2959
2960 let proofs = face_learning_events(&conn, 50, None, Some("other/model")).unwrap();
2962 let proof = proofs
2963 .iter()
2964 .find(|proof| proof.event.id == subject_event_id)
2965 .unwrap();
2966 assert!(proof.incompatible);
2967
2968 let (conn, question_id, profile_id) = qf::library();
2970 answer_question_with_learning(
2971 &conn,
2972 question_id,
2973 videre_core::face_learning::QuestionAnswer::No,
2974 &qf::context(profile_id),
2975 )
2976 .unwrap();
2977 let before: String = conn
2978 .query_row(
2979 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2980 [],
2981 |row| row.get(0),
2982 )
2983 .unwrap();
2984 delete_person_with_learning(&conn, "Alice").unwrap();
2985 let after: String = conn
2986 .query_row(
2987 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2988 [],
2989 |row| row.get(0),
2990 )
2991 .unwrap();
2992 assert_eq!(before, after, "historical feature JSON never mutates");
2993 }
2994
2995 #[test]
2996 fn yes_confirms_the_target_and_teaches_positive_membership() {
2997 let (conn, question_id, profile_id) = qf::library();
2998 let outcome = answer_question_with_learning(
2999 &conn,
3000 question_id,
3001 QuestionAnswer::Yes,
3002 &qf::context(profile_id),
3003 )
3004 .unwrap();
3005 assert_eq!(outcome.status, "answered");
3006 let ack = outcome.acknowledgement.expect("yes must teach");
3007 assert_eq!(ack.event_ids.len(), 1);
3008 assert_eq!(ack.generation, 1);
3009
3010 let labeled: i64 = conn
3011 .query_row(
3012 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND person_label = 'alice'
3013 AND confirmed = 1 AND cluster_id IS NULL",
3014 [],
3015 |row| row.get(0),
3016 )
3017 .unwrap();
3018 assert_eq!(labeled, 2, "yes labels the whole subject cluster");
3019
3020 let event: (String, String, String) = conn
3021 .query_row(
3022 "SELECT action_kind, outcome, target_identity FROM face_learning_events",
3023 [],
3024 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3025 )
3026 .unwrap();
3027 assert_eq!(event.0, "question_yes");
3028 assert_eq!(event.1, "positive");
3029 assert_eq!(event.2, "alice");
3030 }
3031
3032 #[test]
3033 fn no_teaches_negative_without_labeling() {
3034 let (conn, question_id, profile_id) = qf::library();
3035 let outcome = answer_question_with_learning(
3036 &conn,
3037 question_id,
3038 QuestionAnswer::No,
3039 &qf::context(profile_id),
3040 )
3041 .unwrap();
3042 assert_eq!(outcome.status, "answered");
3043
3044 let untouched: i64 = conn
3045 .query_row(
3046 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 0
3047 AND person_label IS NULL AND cluster_id = 1",
3048 [],
3049 |row| row.get(0),
3050 )
3051 .unwrap();
3052 assert_eq!(untouched, 2, "no must not label");
3053
3054 let event: (String, String) = conn
3055 .query_row(
3056 "SELECT action_kind, outcome FROM face_learning_events",
3057 [],
3058 |row| Ok((row.get(0)?, row.get(1)?)),
3059 )
3060 .unwrap();
3061 assert_eq!(event.0, "question_no");
3062 assert_eq!(event.1, "negative");
3063 }
3064
3065 #[test]
3066 fn skip_only_changes_delivery_state() {
3067 let (conn, question_id, profile_id) = qf::library();
3068 let outcome = answer_question_with_learning(
3069 &conn,
3070 question_id,
3071 QuestionAnswer::Skip,
3072 &qf::context(profile_id),
3073 )
3074 .unwrap();
3075 assert_eq!(outcome.status, "skipped");
3076 assert!(outcome.acknowledgement.is_none());
3077
3078 let events: i64 = conn
3079 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
3080 row.get(0)
3081 })
3082 .unwrap();
3083 assert_eq!(events, 0, "skip produces no event");
3084 let state = learning_state(&conn).unwrap();
3085 assert_eq!(state.generation, 0, "skip does not advance generation");
3086 }
3087
3088 #[test]
3089 fn stale_answers_conflict_without_partial_writes() {
3090 let (conn, question_id, profile_id) = qf::library();
3092 assign(&conn, &[10, 11], "Bob").unwrap();
3093 assert!(matches!(
3094 answer_question_with_learning(
3095 &conn,
3096 question_id,
3097 QuestionAnswer::Yes,
3098 &qf::context(profile_id)
3099 ),
3100 Err(Error::Conflict)
3101 ));
3102 let events: i64 = conn
3103 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
3104 row.get(0)
3105 })
3106 .unwrap();
3107 assert_eq!(events, 0, "a conflict must not teach");
3108 assert_eq!(
3109 videre_core::face_learning::stored_question(&conn, question_id)
3110 .unwrap()
3111 .unwrap()
3112 .status,
3113 QuestionStatus::Superseded
3114 );
3115
3116 let (conn, question_id, profile_id) = qf::library();
3119 conn.execute_batch(
3120 "UPDATE faces SET person_label = NULL, confirmed = 0 WHERE person_label = 'alice';
3121 DELETE FROM people WHERE name = 'alice';",
3122 )
3123 .unwrap();
3124 assert!(matches!(
3125 answer_question_with_learning(
3126 &conn,
3127 question_id,
3128 QuestionAnswer::No,
3129 &qf::context(profile_id)
3130 ),
3131 Err(Error::Conflict)
3132 ));
3133 assert_eq!(
3134 videre_core::face_learning::stored_question(&conn, question_id)
3135 .unwrap()
3136 .unwrap()
3137 .status,
3138 QuestionStatus::Superseded
3139 );
3140
3141 let (conn, question_id, profile_id) = qf::library();
3143 conn.execute("UPDATE face_learning_profiles SET status = 'retired'", [])
3144 .unwrap();
3145 let _ = profile_id;
3146 assert!(matches!(
3147 answer_question_with_learning(&conn, question_id, QuestionAnswer::No, &qf::context(99)),
3148 Err(Error::Conflict)
3149 ));
3150 assert_eq!(
3151 videre_core::face_learning::stored_question(&conn, question_id)
3152 .unwrap()
3153 .unwrap()
3154 .status,
3155 QuestionStatus::Superseded
3156 );
3157
3158 let (conn, question_id, profile_id) = qf::library();
3160 assign(&conn, &[13], "Alice").unwrap();
3161 remove_face(&conn, 12).unwrap();
3162 insert_face_with_score(&conn, 14, None, 0.9);
3163 assign(&conn, &[14], "Alice").unwrap();
3164 assert!(matches!(
3165 answer_question_with_learning(
3166 &conn,
3167 question_id,
3168 QuestionAnswer::No,
3169 &qf::context(profile_id)
3170 ),
3171 Err(Error::Conflict)
3172 ));
3173 let question = videre_core::face_learning::stored_question(&conn, question_id)
3174 .unwrap()
3175 .unwrap();
3176 assert_eq!(
3177 question.status,
3178 videre_core::face_learning::QuestionStatus::Superseded
3179 );
3180 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
3181 }
3182
3183 fn insert_face_with_score(conn: &Connection, id: i64, cluster: Option<i64>, score: f64) {
3184 conn.execute(
3185 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
3186 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, ?4, 600.0)",
3187 rusqlite::params![id, qf::embedding_blob(1.0, 0.0), cluster, score],
3188 )
3189 .unwrap();
3190 }
3191
3192 #[test]
3193 fn faces_moved_out_of_the_question_cluster_conflict() {
3194 let (conn, question_id, profile_id) = qf::library();
3195 conn.execute("UPDATE faces SET cluster_id = 9 WHERE id = 11", [])
3198 .unwrap();
3199 assert!(matches!(
3200 answer_question_with_learning(
3201 &conn,
3202 question_id,
3203 QuestionAnswer::Yes,
3204 &qf::context(profile_id)
3205 ),
3206 Err(Error::Conflict)
3207 ));
3208
3209 let labeled: i64 = conn
3210 .query_row(
3211 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
3212 [],
3213 |row| row.get(0),
3214 )
3215 .unwrap();
3216 assert_eq!(labeled, 0, "a stale cluster must not label");
3217 let events: i64 = conn
3218 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
3219 row.get(0)
3220 })
3221 .unwrap();
3222 assert_eq!(events, 0);
3223 let question = videre_core::face_learning::stored_question(&conn, question_id)
3224 .unwrap()
3225 .unwrap();
3226 assert_eq!(
3227 question.status,
3228 videre_core::face_learning::QuestionStatus::Superseded
3229 );
3230 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
3231 }
3232
3233 #[test]
3234 fn refresh_creates_question_tables_for_a_first_training_cycle() {
3235 let (conn, _, _) = qf::library();
3236 conn.execute_batch(
3237 "DROP TABLE face_learning_question_faces;
3238 DROP TABLE face_learning_questions;",
3239 )
3240 .unwrap();
3241
3242 let questions = refresh_identity_questions(&conn, &QuestionSelectionConfig::default())
3243 .expect("a promoted profile should create the question tables");
3244 assert_eq!(questions.len(), 1);
3245 assert_eq!(pending_identity_questions(&conn, 5).unwrap().len(), 1);
3246 }
3247
3248 #[test]
3252 fn v2_library_enforces_keys_through_the_public_paths() {
3253 use videre_core::face_learning::QuestionAnswer as Answer;
3254 let root = tempfile::tempdir().unwrap();
3255 let cache = tempfile::tempdir().unwrap();
3256 let ctx = videre_core::library::LibraryContext::new(root.path(), cache.path()).unwrap();
3257 let conn = videre_core::library_db::initialize(&ctx).unwrap();
3258 let keys_on: i64 = conn
3259 .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
3260 .unwrap();
3261 assert_eq!(keys_on, 1, "an initialized library verifies enforcement");
3262
3263 conn.execute_batch(
3266 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur) VALUES
3267 (1, 'k1', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0),
3268 (2, 'k2', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0);
3269 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice'), ('bob', 'Bob');",
3270 )
3271 .unwrap();
3272 assign(&conn, &[1], "Alice").unwrap();
3273 assign(&conn, &[2], "Bob").unwrap();
3274
3275 assert!(conn
3278 .execute(
3279 "INSERT INTO faces (hash,bbox,embedding,person_label,confirmed)
3280 VALUES ('k9','0,0,9,9',X'0000','ghost',1)",
3281 [],
3282 )
3283 .is_err());
3284 assert!(conn
3285 .execute(
3286 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
3287 VALUES (999, 1, 'subject', 0)",
3288 [],
3289 )
3290 .is_err());
3291
3292 conn.execute(
3294 "INSERT INTO face_learning_events (id, action_kind, decision_kind, outcome,
3295 embedding_model_id, feature_schema_version, target_identity,
3296 feature_snapshot_json, support_count)
3297 VALUES (1, 'assign_face', 'membership', 'positive', 'x/1', 1, 'alice', '{}', 0)",
3298 [],
3299 )
3300 .unwrap();
3301 conn.execute(
3302 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
3303 VALUES (1, 1, 'subject', 0)",
3304 [],
3305 )
3306 .unwrap();
3307
3308 delete_person_with_learning(&conn, "Alice").unwrap();
3311 let state: (i64, Option<String>) = conn
3312 .query_row(
3313 "SELECT confirmed, person_label FROM faces WHERE id = 1",
3314 [],
3315 |r| Ok((r.get(0)?, r.get(1)?)),
3316 )
3317 .unwrap();
3318 assert_eq!(state, (0, None));
3319
3320 let question = videre_core::face_learning::select_questions(
3322 &conn,
3323 &videre_core::face_learning::QuestionSelectionConfig::default(),
3324 )
3325 .unwrap();
3326 if !question.is_empty() {
3327 let stored =
3328 videre_core::face_learning::replace_pending_questions(&conn, &question).unwrap();
3329 conn.execute(
3330 "UPDATE face_learning_questions SET evidence_revision = 'stale' WHERE id = ?1",
3331 rusqlite::params![stored[0].id],
3332 )
3333 .unwrap();
3334 let context = TeachingContext {
3335 embedding_model_id: "x/1".into(),
3336 active_profile_id: None,
3337 record: true,
3338 };
3339 assert!(matches!(
3340 answer_question_with_learning(&conn, stored[0].id, Answer::Yes, &context),
3341 Err(Error::Conflict)
3342 ));
3343 }
3344
3345 videre_core::face_db::reset_all(&conn).unwrap();
3347 for table in [
3348 "face_learning_events",
3349 "face_learning_event_faces",
3350 "face_learning_questions",
3351 "face_learning_question_faces",
3352 "face_learning_profiles",
3353 ] {
3354 let n: i64 = conn
3355 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
3356 .unwrap();
3357 assert_eq!(n, 0, "{table} must be empty after reset");
3358 }
3359 let violations: i64 = conn
3360 .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
3361 r.get(0)
3362 })
3363 .unwrap();
3364 assert_eq!(violations, 0);
3365 }
3366
3367 #[test]
3368 fn yes_cannot_label_without_evidence() {
3369 let (conn, question_id, profile_id) = qf::library();
3370 conn.execute_batch(
3371 "CREATE TRIGGER abort_question_events
3372 BEFORE INSERT ON face_learning_events
3373 BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;",
3374 )
3375 .unwrap();
3376 assert!(answer_question_with_learning(
3377 &conn,
3378 question_id,
3379 QuestionAnswer::Yes,
3380 &qf::context(profile_id)
3381 )
3382 .is_err());
3383
3384 let labeled: i64 = conn
3385 .query_row(
3386 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
3387 [],
3388 |row| row.get(0),
3389 )
3390 .unwrap();
3391 assert_eq!(labeled, 0, "yes cannot label without its evidence row");
3392
3393 let question = videre_core::face_learning::stored_question(&conn, question_id)
3394 .unwrap()
3395 .unwrap();
3396 assert_eq!(
3397 question.status,
3398 videre_core::face_learning::QuestionStatus::Pending
3399 );
3400 }
3401}