1use crate::error::{Error, Result};
6use crate::types::*;
7use rusqlite::Connection;
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 || face_ids.iter().copied().collect::<BTreeSet<_>>().len() != face_ids.len()
51 {
52 return Err(Error::Invalid);
53 }
54 let mut ids = face_ids.to_vec();
55 ids.sort_unstable();
56 let mut statement =
57 conn.prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id = ?1")?;
58 ids.into_iter()
59 .map(|id| {
60 statement
61 .query_row([id], |row| {
62 Ok(FaceState {
63 id,
64 cluster_id: row.get(0)?,
65 person_label: row.get(1)?,
66 confirmed: row.get::<_, i64>(2)? != 0,
67 })
68 })
69 .map_err(|error| match error {
70 rusqlite::Error::QueryReturnedNoRows => Error::NotFound,
71 other => other.into(),
72 })
73 })
74 .collect()
75}
76
77fn unassigned_cluster_ids(conn: &Connection, cluster_id: i64) -> Result<Vec<i64>> {
78 let mut statement = conn.prepare(
79 "SELECT id FROM faces
80 WHERE cluster_id = ?1 AND confirmed = 0 AND person_label IS NULL
81 ORDER BY id",
82 )?;
83 let ids = statement
84 .query_map([cluster_id], |row| row.get(0))?
85 .collect::<rusqlite::Result<_>>()?;
86 Ok(ids)
87}
88
89fn person_support_ids(conn: &Connection, identity: &str, excluded: &[i64]) -> Result<Vec<i64>> {
90 let excluded: BTreeSet<_> = excluded.iter().copied().collect();
91 let mut statement = conn.prepare(
92 "SELECT id FROM faces
93 WHERE person_label = ?1 AND confirmed = 1 AND cluster_id IS NULL
94 ORDER BY is_primary DESC, id ASC",
95 )?;
96 let ids = statement
97 .query_map([identity], |row| row.get(0))?
98 .collect::<rusqlite::Result<Vec<i64>>>()?
99 .into_iter()
100 .filter(|id| !excluded.contains(id))
101 .take(MAX_SUPPORT_FACES)
102 .collect();
103 Ok(ids)
104}
105
106fn event_faces(subject: &[i64], support: &[i64], support_role: EventFaceRole) -> Vec<EventFaceRef> {
107 subject
108 .iter()
109 .enumerate()
110 .map(|(ordinal, face_id)| EventFaceRef {
111 face_id: *face_id,
112 role: EventFaceRole::Subject,
113 ordinal: ordinal as u32,
114 })
115 .chain(
116 support
117 .iter()
118 .enumerate()
119 .map(|(ordinal, face_id)| EventFaceRef {
120 face_id: *face_id,
121 role: support_role,
122 ordinal: ordinal as u32,
123 }),
124 )
125 .collect()
126}
127
128fn membership_event(
129 conn: &Connection,
130 subject_ids: &[i64],
131 support_ids: &[i64],
132 action: LearningAction,
133 outcome: LearningOutcome,
134 target_identity: Option<String>,
135 context: &TeachingContext,
136 stage: DecisionStage,
137) -> Result<NewLearningEvent> {
138 let subject = load_face_observations(conn, subject_ids)?;
139 let support = load_face_observations(conn, support_ids)?;
140 Ok(NewLearningEvent {
141 action,
142 decision_kind: LearningDecisionKind::Membership,
143 outcome,
144 embedding_model_id: context.embedding_model_id.clone(),
145 active_profile_id: context.active_profile_id,
146 target_identity,
147 features: extract_membership_features(&subject, &support, stage)?,
148 support_count: support.len() as u32,
149 scorer_confidence: None,
150 faces: event_faces(subject_ids, support_ids, EventFaceRole::TargetSupport),
151 })
152}
153
154fn cluster_event(
155 conn: &Connection,
156 face_ids: &[i64],
157 action: LearningAction,
158 outcome: LearningOutcome,
159 target_identity: Option<String>,
160 context: &TeachingContext,
161) -> Result<NewLearningEvent> {
162 let cluster = load_face_observations(conn, face_ids)?;
163 Ok(NewLearningEvent {
164 action,
165 decision_kind: LearningDecisionKind::ClusterQuality,
166 outcome,
167 embedding_model_id: context.embedding_model_id.clone(),
168 active_profile_id: context.active_profile_id,
169 target_identity,
170 features: extract_cluster_quality_features(&cluster, DecisionStage::GalleryCluster)?,
171 support_count: cluster.len() as u32,
172 scorer_confidence: None,
173 faces: face_ids
174 .iter()
175 .enumerate()
176 .map(|(ordinal, face_id)| EventFaceRef {
177 face_id: *face_id,
178 role: EventFaceRole::ClusterMember,
179 ordinal: ordinal as u32,
180 })
181 .collect(),
182 })
183}
184
185fn faces_table_exists(conn: &Connection) -> bool {
187 conn.query_row(
188 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='faces'",
189 [],
190 |r| r.get::<_, i64>(0),
191 )
192 .map(|n| n > 0)
193 .unwrap_or(false)
194}
195
196pub fn faces_list(conn: &Connection) -> Result<FacesData> {
208 if !faces_table_exists(conn) {
209 return Ok(FacesData::default());
210 }
211 let mut people: HashMap<String, PersonData> = HashMap::new();
212 {
213 let mut stmt = conn.prepare(
214 "SELECT f.id, f.hash, f.person_label, COALESCE(p.full_name, f.person_label) \
218 FROM faces f LEFT JOIN people p ON p.name = f.person_label \
219 WHERE f.confirmed = 1 AND f.person_label IS NOT NULL \
220 ORDER BY f.person_label, f.is_primary DESC, f.id ASC",
221 )?;
222 let rows = stmt.query_map([], |r| {
223 Ok((
224 r.get::<_, i64>(0)?,
225 r.get::<_, String>(1)?,
226 r.get::<_, String>(2)?,
227 r.get::<_, String>(3)?,
228 ))
229 })?;
230 for row in rows {
231 let (id, hash, label, full_name) = row?;
232 let person = people.entry(label.clone()).or_insert(PersonData {
233 label: label.clone(),
234 full_name,
235 face_ids: vec![],
236 representative_id: id,
237 hashes: vec![],
238 });
239 person.face_ids.push(id);
240 if !person.hashes.contains(&hash) {
241 person.hashes.push(hash);
242 }
243 }
244 }
245
246 let mut cluster_map: HashMap<i64, ClusterData> = HashMap::new();
247 {
248 let mut stmt = conn.prepare(
249 "SELECT id, hash, cluster_id FROM faces \
250 WHERE cluster_id IS NOT NULL AND (confirmed = 0 OR person_label IS NULL) \
251 ORDER BY cluster_id, id",
252 )?;
253 let rows = stmt.query_map([], |r| {
254 Ok((
255 r.get::<_, i64>(0)?,
256 r.get::<_, String>(1)?,
257 r.get::<_, i64>(2)?,
258 ))
259 })?;
260 for row in rows {
261 let (id, hash, cid) = row?;
262 let cluster = cluster_map.entry(cid).or_insert(ClusterData {
263 cluster_id: cid,
264 face_ids: vec![],
265 hashes: vec![],
266 });
267 cluster.face_ids.push(id);
268 if !cluster.hashes.contains(&hash) {
269 cluster.hashes.push(hash);
270 }
271 }
272 }
273
274 let mut singletons: Vec<SingletonData> = vec![];
275 {
276 let mut stmt = conn.prepare(
277 "SELECT id, hash FROM faces \
278 WHERE cluster_id IS NULL AND (confirmed = 0 OR person_label IS NULL) \
279 ORDER BY id",
280 )?;
281 let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
282 for row in rows {
283 let (id, hash) = row?;
284 singletons.push(SingletonData { face_id: id, hash });
285 }
286 }
287
288 let mut people: Vec<PersonData> = people.into_values().collect();
300 people.sort_by_key(|a| a.full_name.to_lowercase());
301 let mut clusters: Vec<ClusterData> = cluster_map.into_values().collect();
302 clusters.sort_by(|a, b| {
303 b.face_ids
304 .len()
305 .cmp(&a.face_ids.len())
306 .then(a.cluster_id.cmp(&b.cluster_id))
307 });
308
309 Ok(FacesData {
310 people,
311 clusters,
312 singletons,
313 })
314}
315
316pub fn cluster_detail(conn: &Connection, cluster_id: i64) -> Result<ClusterDetail> {
318 let mut stmt = conn.prepare(
322 "SELECT f.id, f.hash, fh.path FROM faces f \
323 JOIN file_hashes fh ON f.hash = fh.hash \
324 WHERE f.cluster_id = ?1 AND (f.confirmed = 0 OR f.person_label IS NULL) \
325 ORDER BY f.id",
326 )?;
327 let faces = stmt
328 .query_map([cluster_id], |r| {
329 Ok(ClusterFaceData {
330 face_id: r.get(0)?,
331 hash: r.get(1)?,
332 path: r.get(2)?,
333 })
334 })?
335 .collect::<rusqlite::Result<Vec<_>>>()?;
336 Ok(ClusterDetail { cluster_id, faces })
337}
338
339pub fn person_detail(conn: &Connection, name: &str) -> Result<PersonDetail> {
341 let name = videre_core::person::normalize(name).unwrap_or_else(|| name.to_string());
345 let name = name.as_str();
346 let mut stmt = conn.prepare(
347 "SELECT f.id, f.hash, fh.path, f.is_primary FROM faces f \
348 JOIN file_hashes fh ON f.hash = fh.hash \
349 WHERE f.person_label = ?1 AND f.confirmed = 1 \
350 ORDER BY f.is_primary DESC, f.id",
351 )?;
352 let faces = stmt
353 .query_map([name], |r| {
354 Ok(PersonFaceData {
355 face_id: r.get(0)?,
356 hash: r.get(1)?,
357 path: r.get(2)?,
358 is_primary: r.get::<_, i64>(3)? != 0,
359 })
360 })?
361 .collect::<rusqlite::Result<Vec<_>>>()?;
362 let full_name: String = conn
365 .query_row(
366 "SELECT full_name FROM people WHERE name = ?1",
367 rusqlite::params![name],
368 |r| r.get(0),
369 )
370 .unwrap_or_else(|_| name.to_string());
371 Ok(PersonDetail {
372 label: name.to_string(),
373 full_name,
374 faces,
375 })
376}
377
378pub fn search_person(conn: &Connection, name: &str) -> Result<Vec<String>> {
381 Ok(videre_core::person_search::search_by_person(
382 conn, name, None,
383 )?)
384}
385
386pub fn assign(conn: &Connection, face_ids: &[i64], person_label: &str) -> Result<()> {
389 let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
393 let label = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
394 if face_ids.is_empty() {
397 return Err(Error::Invalid);
398 }
399 conn.execute_batch("BEGIN")?;
404 let result = assign_in_transaction(conn, face_ids, &label, &display);
405 finish_unit_transaction(conn, result)
406}
407
408fn finish_unit_transaction(conn: &Connection, result: Result<()>) -> Result<()> {
409 match result {
410 Ok(()) => {
411 if let Err(error) = conn.execute_batch("COMMIT") {
412 let _ = conn.execute_batch("ROLLBACK");
413 return Err(error.into());
414 }
415 Ok(())
416 }
417 Err(error) => {
418 let _ = conn.execute_batch("ROLLBACK");
419 Err(error)
420 }
421 }
422}
423
424fn assign_in_transaction(
425 conn: &Connection,
426 face_ids: &[i64],
427 identity: &str,
428 display: &str,
429) -> Result<()> {
430 conn.execute(
431 "INSERT INTO people (name, full_name) VALUES (?1, ?2) ON CONFLICT(name) DO NOTHING",
432 rusqlite::params![identity, display],
433 )?;
434 for id in face_ids {
435 let changed = conn.execute(
436 "UPDATE faces
437 SET person_label = ?1, confirmed = 1, cluster_id = NULL
438 WHERE id = ?2",
439 rusqlite::params![identity, id],
440 )?;
441 if changed == 0 {
442 return Err(Error::NotFound);
443 }
444 }
445 Ok(())
446}
447
448fn validate_teaching_subject(conn: &Connection, face_ids: &[i64]) -> Result<Vec<FaceState>> {
449 let states = face_states(conn, face_ids)?;
450 if states
451 .iter()
452 .any(|state| state.confirmed || state.person_label.is_some())
453 {
454 return Err(Error::Invalid);
455 }
456 if states.len() == 1 && states[0].cluster_id.is_some() {
457 return Err(Error::Invalid);
458 }
459 if states.len() > 1 {
460 let cluster_id = states[0].cluster_id.ok_or(Error::Invalid)?;
461 if states
462 .iter()
463 .any(|state| state.cluster_id != Some(cluster_id))
464 || unassigned_cluster_ids(conn, cluster_id)?
465 != states.iter().map(|state| state.id).collect::<Vec<_>>()
466 {
467 return Err(Error::Invalid);
468 }
469 }
470 Ok(states)
471}
472
473fn assignment_events(
474 conn: &Connection,
475 states: &[FaceState],
476 identity: &str,
477 existing_support: &[i64],
478 context: &TeachingContext,
479 creating_person: bool,
480) -> Result<Vec<NewLearningEvent>> {
481 let ids: Vec<_> = states.iter().map(|state| state.id).collect();
482 let clustered = ids.len() > 1;
483 if !clustered && creating_person {
484 load_face_observations(conn, &ids)?;
488 return Ok(Vec::new());
489 }
490 let action = match (creating_person, clustered) {
491 (true, true) => LearningAction::LabelCluster,
492 (true, false) => LearningAction::CreatePerson,
493 (false, true) => LearningAction::AssignCluster,
494 (false, false) => LearningAction::AssignFace,
495 };
496 let mut events = Vec::new();
497 if clustered {
498 events.push(cluster_event(
499 conn,
500 &ids,
501 action,
502 LearningOutcome::Positive,
503 Some(identity.to_owned()),
504 context,
505 )?);
506 }
507 if creating_person {
508 for (index, subject) in ids
509 .iter()
510 .copied()
511 .take(MAX_MEMBERSHIP_EVENTS_PER_ACTION)
512 .enumerate()
513 {
514 let support: Vec<_> = ids
515 .iter()
516 .copied()
517 .filter(|id| *id != subject)
518 .cycle()
519 .skip(index.min(ids.len().saturating_sub(1)))
520 .take(ids.len().saturating_sub(1).min(MAX_SUPPORT_FACES))
521 .collect();
522 events.push(membership_event(
523 conn,
524 &[subject],
525 &support,
526 action,
527 LearningOutcome::Positive,
528 Some(identity.to_owned()),
529 context,
530 DecisionStage::GalleryCluster,
531 )?);
532 }
533 } else if !existing_support.is_empty() {
534 for subject in ids.iter().copied().take(MAX_MEMBERSHIP_EVENTS_PER_ACTION) {
535 events.push(membership_event(
536 conn,
537 &[subject],
538 existing_support,
539 action,
540 LearningOutcome::Positive,
541 Some(identity.to_owned()),
542 context,
543 if clustered {
544 DecisionStage::GalleryCluster
545 } else {
546 DecisionStage::GallerySingleton
547 },
548 )?);
549 }
550 }
551 Ok(events)
552}
553
554fn assign_teaching(
555 conn: &Connection,
556 face_ids: &[i64],
557 person_label: &str,
558 context: &TeachingContext,
559 creating_person: bool,
560) -> Result<LearningAcknowledgement> {
561 if context.embedding_model_id.trim().is_empty() {
562 return Err(Error::Invalid);
563 }
564 let display = crate::label::sanitize_person_label(person_label).ok_or(Error::Invalid)?;
565 let identity = videre_core::person::normalize(&display).ok_or(Error::Invalid)?;
566 immediate_transaction(conn, || {
567 let states = validate_teaching_subject(conn, face_ids)?;
568 let person_exists = conn.query_row(
569 "SELECT EXISTS(SELECT 1 FROM people WHERE name = ?1)",
570 [&identity],
571 |row| row.get::<_, bool>(0),
572 )?;
573 let creating_person = creating_person && !person_exists;
574 let support = if creating_person {
575 Vec::new()
576 } else {
577 if !person_exists {
578 return Err(Error::NotFound);
579 }
580 person_support_ids(conn, &identity, face_ids)?
581 };
582 let events =
583 assignment_events(conn, &states, &identity, &support, context, creating_person)?;
584 assign_in_transaction(conn, face_ids, &identity, &display)?;
585 if events.is_empty() {
586 let state = learning_state(conn)?;
587 return Ok(LearningAcknowledgement {
588 generation: state.generation,
589 event_ids: Vec::new(),
590 message_key: "face_named_without_comparison".to_owned(),
591 });
592 }
593 let receipt = append_event_batch_in_transaction(conn, &events)?;
594 Ok(LearningAcknowledgement {
595 generation: receipt.generation,
596 event_ids: receipt.event_ids,
597 message_key: if states.len() > 1 {
598 "cluster_confirmed"
599 } else {
600 "membership_confirmed"
601 }
602 .to_owned(),
603 })
604 })
605}
606
607pub fn assign_with_learning(
608 conn: &Connection,
609 face_ids: &[i64],
610 person_label: &str,
611 context: &TeachingContext,
612) -> Result<LearningAcknowledgement> {
613 assign_teaching(conn, face_ids, person_label, context, false)
614}
615
616pub fn new_person_with_learning(
617 conn: &Connection,
618 face_ids: &[i64],
619 person_label: &str,
620 context: &TeachingContext,
621) -> Result<LearningAcknowledgement> {
622 assign_teaching(conn, face_ids, person_label, context, true)
623}
624
625pub fn new_person(conn: &Connection, face_ids: &[i64], label: &str) -> Result<()> {
629 assign(conn, face_ids, label)
630}
631
632pub fn remove_face(conn: &Connection, face_id: i64) -> Result<()> {
634 remove_face_in_transaction(conn, face_id)
638}
639
640fn remove_face_in_transaction(conn: &Connection, face_id: i64) -> Result<()> {
641 let n = conn.execute(
642 "UPDATE faces SET cluster_id = NULL, person_label = NULL, confirmed = 0, is_primary = 0 WHERE id = ?1",
643 [face_id],
644 )?;
645 if n == 0 {
646 return Err(Error::NotFound);
647 }
648 Ok(())
649}
650
651pub fn remove_face_with_learning(
652 conn: &Connection,
653 face_id: i64,
654 context: &TeachingContext,
655) -> Result<LearningAcknowledgement> {
656 if context.embedding_model_id.trim().is_empty() {
657 return Err(Error::Invalid);
658 }
659 immediate_transaction(conn, || {
660 let state = face_states(conn, &[face_id])?.remove(0);
661 let (action, support, identity, stage) =
662 if state.confirmed && state.person_label.is_some() && state.cluster_id.is_none() {
663 let identity = state.person_label.clone().ok_or(Error::Invalid)?;
664 let support = person_support_ids(conn, &identity, &[face_id])?;
665 if support.is_empty() {
666 remove_face_in_transaction(conn, face_id)?;
667 let generation = learning_state(conn)?.generation;
668 return Ok(LearningAcknowledgement {
669 generation,
670 event_ids: Vec::new(),
671 message_key: "face_removed_without_comparison".to_owned(),
672 });
673 }
674 (
675 LearningAction::RemoveFaceFromPerson,
676 support,
677 Some(identity),
678 DecisionStage::GallerySingleton,
679 )
680 } else if !state.confirmed && state.person_label.is_none() {
681 let cluster_id = state.cluster_id.ok_or(Error::Invalid)?;
682 let support: Vec<_> = unassigned_cluster_ids(conn, cluster_id)?
683 .into_iter()
684 .filter(|id| *id != face_id)
685 .take(MAX_SUPPORT_FACES)
686 .collect();
687 if support.is_empty() {
688 remove_face_in_transaction(conn, face_id)?;
689 let generation = learning_state(conn)?.generation;
690 return Ok(LearningAcknowledgement {
691 generation,
692 event_ids: Vec::new(),
693 message_key: "face_removed_without_comparison".to_owned(),
694 });
695 }
696 (
697 LearningAction::RemoveFaceFromCluster,
698 support,
699 None,
700 DecisionStage::GalleryCluster,
701 )
702 } else {
703 return Err(Error::Invalid);
704 };
705 let event = membership_event(
706 conn,
707 &[face_id],
708 &support,
709 action,
710 LearningOutcome::Negative,
711 identity,
712 context,
713 stage,
714 )?;
715 remove_face_in_transaction(conn, face_id)?;
716 let receipt = append_event_batch_in_transaction(conn, &[event])?;
717 Ok(LearningAcknowledgement {
718 generation: receipt.generation,
719 event_ids: receipt.event_ids,
720 message_key: "membership_corrected".to_owned(),
721 })
722 })
723}
724
725pub fn dissolve_cluster(conn: &Connection, cluster_id: i64) -> Result<()> {
727 dissolve_cluster_in_transaction(conn, cluster_id)
731}
732
733fn dissolve_cluster_in_transaction(conn: &Connection, cluster_id: i64) -> Result<()> {
734 let n = conn.execute(
735 "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
736 [cluster_id],
737 )?;
738 if n == 0 {
739 return Err(Error::NotFound);
740 }
741 Ok(())
742}
743
744pub fn dissolve_cluster_with_learning(
745 conn: &Connection,
746 cluster_id: i64,
747 context: &TeachingContext,
748) -> Result<LearningAcknowledgement> {
749 if context.embedding_model_id.trim().is_empty() {
750 return Err(Error::Invalid);
751 }
752 immediate_transaction(conn, || {
753 let face_ids = unassigned_cluster_ids(conn, cluster_id)?;
754 let all_faces: i64 = conn.query_row(
755 "SELECT COUNT(*) FROM faces WHERE cluster_id = ?1",
756 [cluster_id],
757 |row| row.get(0),
758 )?;
759 if all_faces != face_ids.len() as i64 {
760 return Err(Error::Invalid);
761 }
762 if face_ids.len() < 2 {
763 return if face_ids.is_empty() {
764 Err(Error::NotFound)
765 } else {
766 Err(Error::Invalid)
767 };
768 }
769 let event = cluster_event(
770 conn,
771 &face_ids,
772 LearningAction::DissolveCluster,
773 LearningOutcome::Negative,
774 None,
775 context,
776 )?;
777 dissolve_cluster_in_transaction(conn, cluster_id)?;
778 let receipt = append_event_batch_in_transaction(conn, &[event])?;
779 Ok(LearningAcknowledgement {
780 generation: receipt.generation,
781 event_ids: receipt.event_ids,
782 message_key: "cluster_dissolved".to_owned(),
783 })
784 })
785}
786
787pub fn set_full_name(conn: &Connection, name: &str, full_name: &str) -> Result<()> {
798 let display = crate::label::sanitize_person_label(full_name).ok_or(Error::Invalid)?;
799 let name = videre_core::person::normalize(name).ok_or(Error::Invalid)?;
800 let n = conn.execute(
801 "UPDATE people SET full_name = ?1 WHERE name = ?2",
802 rusqlite::params![display, name],
803 )?;
804 if n == 0 {
805 return Err(Error::NotFound);
806 }
807 Ok(())
808}
809
810pub fn delete_person(conn: &Connection, label: &str) -> Result<()> {
811 let label = videre_core::person::normalize(label).unwrap_or_else(|| label.to_string());
812 conn.execute_batch("BEGIN")?;
815 let result = delete_person_in_transaction(conn, &label).map(|_| ());
816 finish_unit_transaction(conn, result)
817}
818
819fn delete_person_in_transaction(conn: &Connection, identity: &str) -> Result<usize> {
820 let changed = conn.execute(
821 "UPDATE faces
822 SET person_label = NULL, confirmed = 0, is_primary = 0, cluster_id = NULL
823 WHERE person_label = ?1",
824 [identity],
825 )?;
826 if changed > 0 {
827 videre_core::library_state::set(
828 conn,
829 videre_core::library_state::FACE_RECLUSTER_WATERMARK,
830 0,
831 )?;
832 }
833 Ok(changed)
834}
835
836pub fn delete_person_with_learning(
837 conn: &Connection,
838 label: &str,
839) -> Result<Option<LearningAcknowledgement>> {
840 let identity = videre_core::person::normalize(label).ok_or(Error::Invalid)?;
841 immediate_transaction(conn, || {
842 let changed = delete_person_in_transaction(conn, &identity)?;
843 if changed == 0 {
844 return Ok(None);
845 }
846 let generation = invalidate_identity_for_removal_in_transaction(conn, &identity)?;
847 Ok(Some(LearningAcknowledgement {
848 generation,
849 event_ids: Vec::new(),
850 message_key: "person_removed".to_owned(),
851 }))
852 })
853}
854
855pub fn answer_question_with_learning(
862 conn: &Connection,
863 question_id: i64,
864 answer: QuestionAnswer,
865 context: &TeachingContext,
866) -> Result<QuestionAnswerOutcome> {
867 if context.embedding_model_id.trim().is_empty() {
868 return Err(Error::Invalid);
869 }
870 let outcome = immediate_transaction(conn, || {
871 let question = stored_question(conn, question_id)?;
872 let question = match question {
873 Some(question) if question.status == QuestionStatus::Pending => question,
874 _ => return Err(Error::NotFound),
875 };
876 let supersede = || {
877 finish_question_in_transaction(conn, question_id, QuestionStatus::Superseded)?;
878 Ok(None)
879 };
880 let states = match face_states(conn, &question.subject_face_ids) {
881 Ok(states) => states,
882 Err(Error::NotFound) => return supersede(),
883 Err(error) => return Err(error),
884 };
885 if states
886 .iter()
887 .any(|state| state.confirmed || state.person_label.is_some())
888 {
889 return supersede();
890 }
891 if states
895 .iter()
896 .any(|state| state.cluster_id != Some(question.cluster_id))
897 {
898 return supersede();
899 }
900 let display: String = match conn.query_row(
901 "SELECT full_name FROM people WHERE name = ?1",
902 [&question.target_identity],
903 |row| row.get(0),
904 ) {
905 Ok(display) => display,
906 Err(rusqlite::Error::QueryReturnedNoRows) => return supersede(),
907 Err(error) => return Err(error.into()),
908 };
909 let active = active_question_context(conn)?;
910 let Some(active) = active else {
911 return supersede();
912 };
913 if active.profile_id != question.profile_id || active.model_kind != question.model_kind {
914 return supersede();
915 }
916 let representative: i64 = match conn.query_row(
917 "SELECT f.id FROM faces AS f
918 JOIN face_learning_question_faces AS qf
919 ON qf.face_id = f.id AND qf.question_id = ?1 AND qf.role = 'subject'
920 WHERE f.confirmed = 0 AND f.person_label IS NULL
921 ORDER BY f.is_primary DESC, f.det_score DESC, f.id ASC
922 LIMIT 1",
923 [question_id],
924 |row| row.get(0),
925 ) {
926 Ok(representative) => representative,
927 Err(rusqlite::Error::QueryReturnedNoRows) => return supersede(),
928 Err(error) => return Err(error.into()),
929 };
930 let support = person_support_ids(conn, &question.target_identity, &[])?;
931 let subject_observation = load_face_observations(conn, &[representative])?;
932 let support_observation = load_face_observations(conn, &support)?;
933 let features = extract_membership_features(
934 &subject_observation,
935 &support_observation,
936 DecisionStage::Question,
937 )?;
938 let revision = question_evidence_revision(
939 question.profile_id,
940 question.model_kind.as_str(),
941 &question.subject_face_ids,
942 &question.target_identity,
943 &features,
944 active.membership_threshold,
945 &support,
946 );
947 if revision != question.evidence_revision {
948 return supersede();
949 }
950 match answer {
951 QuestionAnswer::Skip => {
952 finish_question_in_transaction(conn, question_id, QuestionStatus::Skipped)?;
953 Ok(Some(QuestionAnswerOutcome {
954 status: "skipped".into(),
955 acknowledgement: None,
956 }))
957 }
958 QuestionAnswer::Yes => {
959 assign_in_transaction(
960 conn,
961 &question.subject_face_ids,
962 &question.target_identity,
963 &display,
964 )?;
965 let event = membership_event(
966 conn,
967 &[representative],
968 &support,
969 LearningAction::QuestionYes,
970 LearningOutcome::Positive,
971 Some(question.target_identity.clone()),
972 context,
973 DecisionStage::Question,
974 )?;
975 let receipt = append_event_batch_in_transaction(conn, &[event])?;
976 finish_question_in_transaction(conn, question_id, QuestionStatus::Answered)?;
977 Ok(Some(QuestionAnswerOutcome {
978 status: "answered".into(),
979 acknowledgement: Some(LearningAcknowledgement {
980 generation: receipt.generation,
981 event_ids: receipt.event_ids,
982 message_key: "question_confirmed".into(),
983 }),
984 }))
985 }
986 QuestionAnswer::No => {
987 let event = membership_event(
988 conn,
989 &[representative],
990 &support,
991 LearningAction::QuestionNo,
992 LearningOutcome::Negative,
993 Some(question.target_identity.clone()),
994 context,
995 DecisionStage::Question,
996 )?;
997 let receipt = append_event_batch_in_transaction(conn, &[event])?;
998 finish_question_in_transaction(conn, question_id, QuestionStatus::Answered)?;
999 Ok(Some(QuestionAnswerOutcome {
1000 status: "answered".into(),
1001 acknowledgement: Some(LearningAcknowledgement {
1002 generation: receipt.generation,
1003 event_ids: receipt.event_ids,
1004 message_key: "question_corrected".into(),
1005 }),
1006 }))
1007 }
1008 }
1009 })?;
1010 outcome.ok_or(Error::Conflict)
1011}
1012
1013pub fn pending_identity_questions(
1016 conn: &Connection,
1017 limit: usize,
1018) -> Result<Vec<videre_core::face_learning::StoredQuestion>> {
1019 Ok(list_pending_questions(conn, limit)?)
1020}
1021
1022pub fn refresh_identity_questions(
1025 conn: &Connection,
1026 config: &QuestionSelectionConfig,
1027) -> Result<Vec<videre_core::face_learning::StoredQuestion>> {
1028 videre_core::face_learning::ensure_question_tables(conn)?;
1029 let candidates = select_questions(conn, config)?;
1030 Ok(replace_pending_questions(conn, &candidates)?)
1031}
1032
1033pub fn face_learning_status(conn: &Connection) -> Result<FaceLearningStatus> {
1035 videre_core::face_learning::ensure_learning_tables(conn)?;
1036 videre_core::face_learning::ensure_question_tables(conn)?;
1037 let state = videre_core::face_learning::learning_state(conn)?;
1038 let pending_questions = conn.query_row(
1039 "SELECT count(*) FROM face_learning_questions WHERE status = 'pending'",
1040 [],
1041 |row| row.get::<_, i64>(0),
1042 )?;
1043 let last_candidate = match state.last_profile_id {
1046 Some(id) => {
1047 videre_core::face_learning::ensure_profile_table(conn)?;
1048 conn.query_row(
1049 "SELECT status FROM face_learning_profiles WHERE id = ?1",
1050 [id],
1051 |row| row.get::<_, String>(0),
1052 )
1053 .map(Some)
1054 .or_else(|error| match error {
1055 rusqlite::Error::QueryReturnedNoRows => Ok(None),
1056 other => Err(other),
1057 })?
1058 .and_then(|status| match status.as_str() {
1059 "active" | "retired" => Some("promoted".to_string()),
1060 "rejected" => Some("rejected".to_string()),
1061 _ => None,
1062 })
1063 }
1064 None => None,
1065 };
1066 let waiting = state.status == videre_core::face_learning::LearningStatus::Waiting;
1067 let failed = state.status == videre_core::face_learning::LearningStatus::Failed;
1068 Ok(FaceLearningStatus {
1069 generation: state.generation,
1070 trained_generation: state.trained_generation,
1071 status: format!("{:?}", state.status).to_lowercase(),
1072 last_profile_id: state.last_profile_id,
1073 last_candidate,
1074 last_error: state.last_error.filter(|_| failed),
1077 feedback_needed: state.feedback_needed.filter(|_| waiting),
1078 pending_questions: pending_questions as usize,
1079 })
1080}
1081
1082#[derive(Debug, Clone, serde::Serialize)]
1087pub struct FaceLearningEventProof {
1088 #[serde(flatten)]
1089 pub event: videre_core::face_learning::StoredLearningEvent,
1090 pub source_available: bool,
1091 pub incompatible: bool,
1092}
1093
1094fn proof_for(
1095 conn: &Connection,
1096 event: videre_core::face_learning::StoredLearningEvent,
1097 current_embedding_model_id: Option<&str>,
1098) -> Result<FaceLearningEventProof> {
1099 let mut source_available = true;
1100 for face in &event.faces {
1101 let exists: bool = conn.query_row(
1102 "SELECT EXISTS(SELECT 1 FROM faces WHERE id = ?1)",
1103 [face.face_id],
1104 |row| row.get(0),
1105 )?;
1106 if !exists {
1107 source_available = false;
1108 break;
1109 }
1110 }
1111 let incompatible = event.features.schema_version
1112 != videre_core::face_learning::FEATURE_SCHEMA_VERSION
1113 || current_embedding_model_id.is_some_and(|model| model != event.embedding_model_id);
1114 Ok(FaceLearningEventProof {
1115 event,
1116 source_available,
1117 incompatible,
1118 })
1119}
1120
1121pub fn face_learning_events(
1124 conn: &Connection,
1125 limit: usize,
1126 before_id: Option<i64>,
1127 current_embedding_model_id: Option<&str>,
1128) -> Result<Vec<FaceLearningEventProof>> {
1129 videre_core::face_learning::ensure_learning_tables(conn)?;
1130 let limit = limit.clamp(1, 200);
1131 let events = list_learning_events(conn, limit, before_id)?;
1132 events
1133 .into_iter()
1134 .map(|event| proof_for(conn, event, current_embedding_model_id))
1135 .collect()
1136}
1137
1138pub fn face_learning_event(
1139 conn: &Connection,
1140 event_id: i64,
1141 current_embedding_model_id: Option<&str>,
1142) -> Result<Option<FaceLearningEventProof>> {
1143 videre_core::face_learning::ensure_learning_tables(conn)?;
1144 match videre_core::face_learning::learning_event(conn, event_id)? {
1145 Some(event) => Ok(Some(proof_for(conn, event, current_embedding_model_id)?)),
1146 None => Ok(None),
1147 }
1148}
1149
1150pub fn load_training_snapshot(
1152 conn: &Connection,
1153 embedding_model_id: &str,
1154 generation: u64,
1155 config: &videre_core::face_learning::TrainingConfig,
1156) -> std::result::Result<videre_core::face_learning::TrainingSnapshot, String> {
1157 let labels =
1158 videre_core::face_db::load_confirmed_face_labels(conn).map_err(|e| e.to_string())?;
1159 let face_ids: Vec<i64> = {
1160 let mut statement = conn
1161 .prepare("SELECT id FROM faces ORDER BY id")
1162 .map_err(|e| e.to_string())?;
1163 let rows = statement
1164 .query_map([], |row| row.get(0))
1165 .map_err(|e| e.to_string())?
1166 .collect::<rusqlite::Result<Vec<i64>>>()
1167 .map_err(|e| e.to_string())?;
1168 rows
1169 };
1170 let observations =
1171 videre_core::face_db::load_face_observations(conn, &face_ids).map_err(|e| e.to_string())?;
1172 let events = videre_core::face_learning::eligible_events_for_training(
1173 conn,
1174 embedding_model_id,
1175 videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1176 )
1177 .map_err(|e| e.to_string())?;
1178 videre_core::face_learning::build_training_snapshot(
1179 generation,
1180 embedding_model_id,
1181 &labels,
1182 &observations,
1183 &events,
1184 config,
1185 )
1186 .map_err(|e| e.to_string())
1187}
1188
1189pub fn persist_trained_profile(
1193 conn: &Connection,
1194 embedding_model_id: &str,
1195 run: &videre_core::face_learning::TrainingRun,
1196 gates: &videre_core::face_learning::PromotionGates,
1197) -> Result<TrainedProfileSummary> {
1198 let validation = match run.comparison.selected {
1199 videre_core::face_learning::CandidateKind::Logistic => &run.logistic_validation,
1200 videre_core::face_learning::CandidateKind::Additive => &run.additive_validation,
1201 };
1202 let profile = videre_core::face_learning::NewProfile {
1203 artifact_version: videre_core::face_learning::PROFILE_ARTIFACT_VERSION,
1204 embedding_model_id: embedding_model_id.to_owned(),
1205 feature_schema_version: videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1206 model_kind: run.selected.model_kind().to_owned(),
1207 parameters: serde_json::to_vec(&run.selected).map_err(Error::from)?,
1208 training_evidence: run.evidence_counts.clone(),
1209 validation_report: validation.clone(),
1210 stage: videre_core::face_learning::ProfileStage::Suggestion,
1211 };
1212 let profile_id = videre_core::face_learning::insert_candidate(conn, &profile)?;
1213 let outcome = videre_core::face_learning::evaluate_and_promote(conn, profile_id, gates)?;
1214 Ok(TrainedProfileSummary {
1215 profile_id,
1216 model_kind: profile.model_kind,
1217 promoted: outcome == videre_core::face_learning::PromotionOutcome::Promoted,
1218 })
1219}
1220
1221pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
1226 let person_label =
1227 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
1228 conn.execute_batch("BEGIN")?;
1229 let result = (|| -> Result<()> {
1230 conn.execute(
1231 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
1232 rusqlite::params![person_label],
1233 )?;
1234 let n = conn.execute(
1239 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
1240 rusqlite::params![person_label, face_id],
1241 )?;
1242 if n == 0 {
1243 return Err(Error::NotFound);
1244 }
1245 Ok(())
1246 })();
1247 match result {
1248 Ok(()) => {
1249 conn.execute_batch("COMMIT")?;
1250 Ok(())
1251 }
1252 Err(e) => {
1253 let _ = conn.execute_batch("ROLLBACK");
1254 Err(e)
1255 }
1256 }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262
1263 #[test]
1264 fn assign_detaches_the_face_from_its_cluster() {
1265 let conn = seed();
1266 assign(&conn, &[3], "Bob").unwrap();
1269 let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
1270 .query_row(
1271 "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
1272 [],
1273 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1274 )
1275 .unwrap();
1276 assert_eq!(label.as_deref(), Some("bob"));
1277 assert_eq!(confirmed, 1);
1278 assert_eq!(cid, None, "assignment must detach the machine grouping");
1279 }
1280
1281 #[test]
1282 fn cluster_detail_never_shows_labeled_faces() {
1283 let conn = seed();
1284 conn.execute(
1288 "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
1289 (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
1290 [],
1291 )
1292 .unwrap();
1293 conn.execute(
1294 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
1295 [],
1296 )
1297 .unwrap();
1298 let detail = cluster_detail(&conn, 7).unwrap();
1299 assert_eq!(
1300 detail.faces.len(),
1301 2,
1302 "only the unlabeled faces of cluster 7 belong on the page"
1303 );
1304 }
1305
1306 pub(super) fn seed() -> Connection {
1313 let conn = Connection::open_in_memory().unwrap();
1314 videre_core::face_db::create_faces_table(&conn).unwrap();
1315 conn.execute_batch(
1316 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1317 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
1318 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
1319 -- Labels are stored in identity form, as `assign` writes them and
1320 -- as the migration leaves them; `people` carries what a reader
1321 -- sees. Seeding raw 'Alice' would test a state the application no
1322 -- longer produces.
1323 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
1324 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1325 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
1326 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
1327 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
1328 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
1329 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
1330 )
1331 .unwrap();
1332 videre_core::db::ensure_file_hashes_columns(&conn);
1333 conn
1334 }
1335
1336 mod learning {
1337 use super::*;
1338 use videre_core::face_learning::{
1339 learning_state, list_learning_events, LearningAction, LearningDecisionKind,
1340 LearningOutcome,
1341 };
1342
1343 fn context() -> TeachingContext {
1344 TeachingContext {
1345 embedding_model_id: "buffalo_l/w600k_r50.onnx".to_owned(),
1346 active_profile_id: None,
1347 }
1348 }
1349
1350 fn embedding(x: u16, y: u16) -> Vec<u8> {
1351 [x.to_le_bytes(), y.to_le_bytes()].concat()
1352 }
1353
1354 fn learning_seed() -> Connection {
1355 let conn = Connection::open_in_memory().unwrap();
1356 videre_core::face_db::create_faces_table(&conn).unwrap();
1357 conn.execute_batch(
1358 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1359 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice');",
1360 )
1361 .unwrap();
1362 let rows = [
1363 (1, "a1", embedding(0x3c00, 0), None, Some("alice"), 1),
1364 (2, "a2", embedding(0x3b9a, 0x3266), None, Some("alice"), 1),
1365 (3, "c1", embedding(0x3c00, 0), Some(7), None, 0),
1366 (4, "c2", embedding(0x3b9a, 0x3266), Some(7), None, 0),
1367 (5, "c3", embedding(0x3b33, 0x34cd), Some(7), None, 0),
1368 (6, "s1", embedding(0x3266, 0x3b9a), None, None, 0),
1369 (7, "d1", embedding(0x3c00, 0), Some(9), None, 0),
1370 (8, "d2", embedding(0, 0x3c00), Some(9), None, 0),
1371 ];
1372 for (id, hash, bytes, cluster, label, confirmed) in rows {
1373 conn.execute(
1374 "INSERT INTO file_hashes (hash, path) VALUES (?1, ?2)",
1375 rusqlite::params![hash, format!("/p/{hash}.jpg")],
1376 )
1377 .unwrap();
1378 conn.execute(
1379 "INSERT INTO faces
1380 (id, hash, bbox, embedding, cluster_id, person_label, confirmed,
1381 is_primary, det_score, blur)
1382 VALUES (?1, ?2, '0,0,112,112', ?3, ?4, ?5, ?6, 0, 0.95, 900.0)",
1383 rusqlite::params![id, hash, bytes, cluster, label, confirmed],
1384 )
1385 .unwrap();
1386 }
1387 conn
1388 }
1389
1390 #[test]
1391 fn learning_assignments_emit_expected_positive_evidence_once_per_action() {
1392 let conn = learning_seed();
1393
1394 let assigned = assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1395 assert_eq!(assigned.generation, 1);
1396 assert_eq!(assigned.event_ids.len(), 1);
1397
1398 let labeled = new_person_with_learning(&conn, &[3, 4, 5], "Bob", &context()).unwrap();
1399 assert_eq!(labeled.generation, 2);
1400 assert_eq!(labeled.event_ids.len(), 4);
1401
1402 let events = list_learning_events(&conn, 20, None).unwrap();
1403 assert_eq!(events.len(), 5);
1404 assert_eq!(
1405 events
1406 .iter()
1407 .filter(|event| event.action == LearningAction::LabelCluster
1408 && event.decision_kind == LearningDecisionKind::ClusterQuality
1409 && event.outcome == LearningOutcome::Positive)
1410 .count(),
1411 1
1412 );
1413 assert_eq!(
1414 events
1415 .iter()
1416 .filter(
1417 |event| event.decision_kind == LearningDecisionKind::Membership
1418 && event.outcome == LearningOutcome::Positive
1419 )
1420 .count(),
1421 4
1422 );
1423 assert!(events.iter().all(|event| {
1424 let json = event.features.to_canonical_json().unwrap();
1425 !json.contains("alice") && !json.contains("bob") && !json.contains("/p/")
1426 }));
1427
1428 let conn = learning_seed();
1429 let assigned_cluster =
1430 assign_with_learning(&conn, &[3, 4, 5], "alice", &context()).unwrap();
1431 assert_eq!(assigned_cluster.generation, 1);
1432 assert_eq!(assigned_cluster.event_ids.len(), 4);
1433 let events = list_learning_events(&conn, 20, None).unwrap();
1434 assert_eq!(
1435 events
1436 .iter()
1437 .filter(|event| event.action == LearningAction::AssignCluster
1438 && event.decision_kind == LearningDecisionKind::Membership)
1439 .count(),
1440 3
1441 );
1442 assert_eq!(
1443 events
1444 .iter()
1445 .filter(|event| event.action == LearningAction::AssignCluster
1446 && event.decision_kind == LearningDecisionKind::ClusterQuality)
1447 .count(),
1448 1
1449 );
1450 }
1451
1452 #[test]
1453 fn a_large_cluster_has_a_deterministic_per_action_membership_cap() {
1454 let conn = learning_seed();
1455 for id in 10..22 {
1456 let hash = format!("large-{id}");
1457 conn.execute(
1458 "INSERT INTO faces
1459 (id, hash, bbox, embedding, cluster_id, confirmed, is_primary,
1460 det_score, blur)
1461 VALUES (?1, ?2, '0,0,112,112', ?3, 42, 0, 0, 0.95, 900.0)",
1462 rusqlite::params![id, hash, embedding(0x3c00, (id as u16) + 0x2000)],
1463 )
1464 .unwrap();
1465 }
1466 let ids: Vec<_> = (10..22).collect();
1467 let acknowledgement =
1468 new_person_with_learning(&conn, &ids, "Large Family", &context()).unwrap();
1469 assert_eq!(
1470 acknowledgement.event_ids.len(),
1471 1 + MAX_MEMBERSHIP_EVENTS_PER_ACTION
1472 );
1473 assert_eq!(learning_state(&conn).unwrap().generation, 1);
1474
1475 let events = list_learning_events(&conn, 20, None).unwrap();
1476 assert_eq!(
1477 events
1478 .iter()
1479 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1480 .count(),
1481 MAX_MEMBERSHIP_EVENTS_PER_ACTION
1482 );
1483 assert!(events
1484 .iter()
1485 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1486 .all(|event| event.support_count as usize <= MAX_SUPPORT_FACES));
1487 }
1488
1489 #[test]
1490 fn learning_corrections_use_pre_action_state_without_pairwise_dissolve_labels() {
1491 let conn = learning_seed();
1492
1493 let removed_cluster = remove_face_with_learning(&conn, 3, &context()).unwrap();
1494 assert_eq!(removed_cluster.generation, 1);
1495 let removed_person = remove_face_with_learning(&conn, 2, &context()).unwrap();
1496 assert_eq!(removed_person.generation, 2);
1497 let dissolved = dissolve_cluster_with_learning(&conn, 9, &context()).unwrap();
1498 assert_eq!(dissolved.generation, 3);
1499
1500 let events = list_learning_events(&conn, 20, None).unwrap();
1501 assert_eq!(events.len(), 3);
1502 assert_eq!(
1503 events
1504 .iter()
1505 .filter(
1506 |event| event.decision_kind == LearningDecisionKind::Membership
1507 && event.outcome == LearningOutcome::Negative
1508 )
1509 .count(),
1510 2
1511 );
1512 let dissolve = events
1513 .iter()
1514 .find(|event| event.action == LearningAction::DissolveCluster)
1515 .unwrap();
1516 assert_eq!(dissolve.decision_kind, LearningDecisionKind::ClusterQuality);
1517 assert_eq!(dissolve.outcome, LearningOutcome::Negative);
1518 assert_eq!(dissolve.faces.len(), 2);
1519 }
1520
1521 #[test]
1522 fn unsupported_last_face_removals_still_apply_without_fabricated_evidence() {
1523 let conn = learning_seed();
1524 remove_face_with_learning(&conn, 1, &context()).unwrap();
1525 let last_person_face = remove_face_with_learning(&conn, 2, &context()).unwrap();
1526 assert!(last_person_face.event_ids.is_empty());
1527 assert_eq!(last_person_face.generation, 1);
1528 let person_state: (Option<String>, i64) = conn
1529 .query_row(
1530 "SELECT person_label, confirmed FROM faces WHERE id = 2",
1531 [],
1532 |row| Ok((row.get(0)?, row.get(1)?)),
1533 )
1534 .unwrap();
1535 assert_eq!(person_state, (None, 0));
1536
1537 remove_face_with_learning(&conn, 3, &context()).unwrap();
1538 remove_face_with_learning(&conn, 4, &context()).unwrap();
1539 let last_cluster_face = remove_face_with_learning(&conn, 5, &context()).unwrap();
1540 assert!(last_cluster_face.event_ids.is_empty());
1541 assert_eq!(last_cluster_face.generation, 3);
1542 let cluster_id: Option<i64> = conn
1543 .query_row("SELECT cluster_id FROM faces WHERE id = 5", [], |row| {
1544 row.get(0)
1545 })
1546 .unwrap();
1547 assert_eq!(cluster_id, None);
1548 }
1549
1550 #[test]
1554 fn following_the_waiting_ask_starts_a_new_run_and_one_face_does_not() {
1555 let conn = learning_seed();
1556 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1557 videre_core::face_learning::mark_training_started(&conn).unwrap();
1558 let ask = videre_core::face_learning::TrainingError::OneSidedFold {
1559 decision_kind: videre_core::face_learning::LearningDecisionKind::Membership,
1560 lacking_negatives: true,
1561 }
1562 .feedback_needed(&videre_core::face_learning::TrainingConfig::default())
1563 .unwrap();
1564 assert_eq!(ask, "name 1 more person from a group of two or more faces");
1565 videre_core::face_learning::mark_training_waiting(&conn, 1, &ask).unwrap();
1566
1567 let single = new_person_with_learning(&conn, &[6], "Çağla", &context()).unwrap();
1568 assert!(single.event_ids.is_empty());
1569 let status = face_learning_status(&conn).unwrap();
1570 assert_eq!(
1571 (status.generation, status.status.as_str()),
1572 (1, "waiting"),
1573 "one face records nothing, so nothing new is trained"
1574 );
1575 assert_eq!(status.feedback_needed.as_deref(), Some(ask.as_str()));
1576
1577 let group = new_person_with_learning(&conn, &[3, 4, 5], "Özgür", &context()).unwrap();
1578 assert!(!group.event_ids.is_empty());
1579 let status = face_learning_status(&conn).unwrap();
1580 assert_eq!(
1581 (status.generation, status.status.as_str()),
1582 (2, "stale"),
1583 "a named group is new evidence, so the worker trains again"
1584 );
1585 assert_eq!(status.feedback_needed, None);
1586 assert_eq!(
1587 status.last_error, None,
1588 "the ask stored for the waiting run never reads as an error"
1589 );
1590 }
1591
1592 #[test]
1596 fn a_stale_state_never_reports_the_waiting_ask_as_an_error() {
1597 let conn = learning_seed();
1598 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1599 videre_core::face_learning::mark_training_started(&conn).unwrap();
1600 videre_core::face_learning::mark_training_waiting(
1601 &conn,
1602 1,
1603 "dissolve 2 more wrong clusters",
1604 )
1605 .unwrap();
1606 conn.execute(
1607 "UPDATE face_learning_state SET generation = generation + 1, status = 'stale'",
1608 [],
1609 )
1610 .unwrap();
1611 let status = face_learning_status(&conn).unwrap();
1612 assert_eq!(status.status, "stale");
1613 assert_eq!(status.last_error, None);
1614 assert_eq!(status.feedback_needed, None);
1615 }
1616
1617 #[test]
1618 fn new_person_collision_uses_existing_person_support() {
1619 let conn = learning_seed();
1620 let acknowledgement =
1621 new_person_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1622 assert_eq!(acknowledgement.generation, 1);
1623 assert_eq!(acknowledgement.event_ids.len(), 1);
1624 let events = list_learning_events(&conn, 10, None).unwrap();
1625 assert_eq!(events[0].action, LearningAction::AssignFace);
1626 assert_eq!(events[0].target_identity.as_deref(), Some("alice"));
1627 assert_eq!(events[0].support_count, 2);
1628 }
1629
1630 #[test]
1631 fn assigning_to_a_face_less_person_keeps_only_supported_evidence() {
1632 let conn = learning_seed();
1633 conn.execute(
1634 "UPDATE faces
1635 SET person_label = NULL, confirmed = 0
1636 WHERE person_label = 'alice'",
1637 [],
1638 )
1639 .unwrap();
1640
1641 let singleton = assign_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1642 assert!(singleton.event_ids.is_empty());
1643 assert_eq!(singleton.generation, 0);
1644 assert_eq!(singleton.message_key, "face_named_without_comparison");
1645 let assigned: (Option<String>, i64) = conn
1646 .query_row(
1647 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1648 [],
1649 |row| Ok((row.get(0)?, row.get(1)?)),
1650 )
1651 .unwrap();
1652 assert_eq!(assigned, (Some("alice".to_owned()), 1));
1653 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1654
1655 let conn = learning_seed();
1656 conn.execute(
1657 "UPDATE faces
1658 SET person_label = NULL, confirmed = 0
1659 WHERE person_label = 'alice'",
1660 [],
1661 )
1662 .unwrap();
1663 let cluster = new_person_with_learning(&conn, &[3, 4, 5], "Alice", &context()).unwrap();
1664 assert_eq!(cluster.event_ids.len(), 1);
1665 assert_eq!(cluster.generation, 1);
1666 let events = list_learning_events(&conn, 10, None).unwrap();
1667 assert_eq!(events.len(), 1);
1668 assert_eq!(events[0].action, LearningAction::AssignCluster);
1669 assert_eq!(
1670 events[0].decision_kind,
1671 LearningDecisionKind::ClusterQuality
1672 );
1673 }
1674
1675 #[test]
1676 fn event_insert_failure_rolls_back_the_visible_assignment_and_generation() {
1677 let conn = learning_seed();
1678 conn.execute_batch(
1679 "CREATE TRIGGER reject_learning_event
1680 BEFORE INSERT ON face_learning_events
1681 BEGIN SELECT RAISE(ABORT, 'test rejection'); END;",
1682 )
1683 .unwrap();
1684
1685 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1686 let state: (Option<String>, i64) = conn
1687 .query_row(
1688 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1689 [],
1690 |row| Ok((row.get(0)?, row.get(1)?)),
1691 )
1692 .unwrap();
1693 assert_eq!(state, (None, 0));
1694 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1695 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1696 }
1697
1698 #[test]
1699 fn commit_failure_rolls_back_faces_events_and_generation() {
1700 let conn = learning_seed();
1701 conn.execute_batch(
1702 "PRAGMA foreign_keys = ON;
1703 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
1704 CREATE TABLE commit_guard_child (
1705 event_id INTEGER PRIMARY KEY,
1706 parent_id INTEGER NOT NULL,
1707 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
1708 DEFERRABLE INITIALLY DEFERRED
1709 );
1710 CREATE TRIGGER fail_learning_commit
1711 AFTER INSERT ON face_learning_events
1712 BEGIN
1713 INSERT INTO commit_guard_child (event_id, parent_id)
1714 VALUES (NEW.id, 999);
1715 END;",
1716 )
1717 .unwrap();
1718
1719 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1720 let state: (Option<String>, i64) = conn
1721 .query_row(
1722 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1723 [],
1724 |row| Ok((row.get(0)?, row.get(1)?)),
1725 )
1726 .unwrap();
1727 assert_eq!(state, (None, 0));
1728 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1729 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1730 }
1731
1732 #[test]
1733 fn malformed_or_mixed_prestate_rolls_back_without_learning() {
1734 let conn = learning_seed();
1735 conn.execute("UPDATE faces SET embedding = X'0000' WHERE id = 6", [])
1736 .unwrap();
1737 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1738 assert!(new_person_with_learning(&conn, &[3, 7], "Bob", &context()).is_err());
1739 assert!(new_person_with_learning(&conn, &[1], "Bob", &context()).is_err());
1740 assert!(assign_with_learning(&conn, &[999], "alice", &context()).is_err());
1741 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1742 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1743 }
1744
1745 #[test]
1746 fn deleting_a_person_invalidates_identity_evidence_without_a_negative_event() {
1747 let conn = learning_seed();
1748 assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1749 let acknowledgement = delete_person_with_learning(&conn, "alice")
1750 .unwrap()
1751 .unwrap();
1752 assert_eq!(acknowledgement.generation, 2);
1753 assert!(acknowledgement.event_ids.is_empty());
1754
1755 let events = list_learning_events(&conn, 20, None).unwrap();
1756 assert_eq!(events.len(), 1);
1757 assert!(!events[0].eligible);
1758 assert_eq!(
1759 events[0].invalidation_reason,
1760 Some(videre_core::face_learning::InvalidationReason::PersonRemoved)
1761 );
1762 assert!(delete_person_with_learning(&conn, "alice")
1763 .unwrap()
1764 .is_none());
1765 assert_eq!(learning_state(&conn).unwrap().generation, 2);
1766 }
1767
1768 #[test]
1769 fn deleting_a_person_without_learning_evidence_keeps_generation_current() {
1770 let conn = learning_seed();
1771 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1772
1773 let acknowledgement = delete_person_with_learning(&conn, "alice")
1774 .unwrap()
1775 .unwrap();
1776
1777 assert_eq!(acknowledgement.generation, 0);
1778 assert!(acknowledgement.event_ids.is_empty());
1779 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1780 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1781 }
1782 }
1783
1784 #[test]
1785 fn the_list_comes_back_in_the_same_order_every_time() {
1786 let conn = seed();
1793 conn.execute_batch(
1797 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
1800 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
1801 INSERT INTO people (name, full_name) VALUES ('bob','Bob');
1802 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1803 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
1804 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
1805 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
1806 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
1807 (10,'h10','0,0,9,9',X'0000',NULL,'bob',1,0);",
1808 )
1809 .unwrap();
1810
1811 let a = faces_list(&conn).unwrap();
1814 let b = faces_list(&conn).unwrap();
1815
1816 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
1817 let names =
1818 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
1819 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
1820 assert_eq!(
1821 ids(&a),
1822 ids(&b),
1823 "cluster order must not change between calls"
1824 );
1825 assert_eq!(
1826 names(&a),
1827 names(&b),
1828 "people order must not change between calls"
1829 );
1830
1831 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
1834 let mut want = sizes.clone();
1835 want.sort_unstable_by(|x, y| y.cmp(x));
1836 assert_eq!(
1837 sizes, want,
1838 "clusters must be ordered largest first, got {sizes:?}"
1839 );
1840 }
1841
1842 #[test]
1843 fn faces_list_splits_people_clusters_singletons() {
1844 let conn = seed();
1845 let d = faces_list(&conn).unwrap();
1846 assert_eq!(d.people.len(), 1);
1847 assert_eq!(d.people[0].label, "alice");
1849 assert_eq!(d.people[0].full_name, "Alice");
1850 assert_eq!(
1851 d.people[0].representative_id, 1,
1852 "primary face is representative"
1853 );
1854 assert_eq!(d.clusters.len(), 1);
1855 assert_eq!(d.clusters[0].cluster_id, 7);
1856 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
1857 assert_eq!(d.singletons.len(), 1);
1858 assert_eq!(d.singletons[0].face_id, 5);
1859 }
1860
1861 #[test]
1862 fn person_detail_marks_primary() {
1863 let conn = seed();
1864 let p = person_detail(&conn, "Alice").unwrap();
1865 assert_eq!(p.faces.len(), 2);
1866 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
1867 assert!(!p.faces[1].is_primary);
1868 }
1869
1870 #[test]
1871 fn cluster_detail_lists_faces() {
1872 let conn = seed();
1873 let c = cluster_detail(&conn, 7).unwrap();
1874 assert_eq!(c.cluster_id, 7);
1875 assert_eq!(
1876 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
1877 vec![3, 4]
1878 );
1879 }
1880
1881 #[test]
1882 fn assign_labels_and_confirms() {
1883 let conn = seed();
1884 assign(&conn, &[3, 4], "Bob").unwrap();
1885 let p = person_detail(&conn, "Bob").unwrap();
1886 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
1887 }
1888
1889 #[test]
1890 fn assign_rejects_empty_label() {
1891 let conn = seed();
1892 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
1893 }
1894
1895 #[test]
1896 fn remove_face_unassigns_everything() {
1897 let conn = seed();
1898 remove_face(&conn, 1).unwrap();
1899 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
1900 .query_row(
1901 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
1902 [],
1903 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
1904 )
1905 .unwrap();
1906 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
1907 }
1908
1909 #[test]
1910 fn dissolve_cluster_nulls_cluster_id() {
1911 let conn = seed();
1912 dissolve_cluster(&conn, 7).unwrap();
1913 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
1914 assert_eq!(
1915 faces_list(&conn).unwrap().singletons.len(),
1916 3,
1917 "3,4 join 5 as singletons"
1918 );
1919 }
1920
1921 #[test]
1922 fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
1923 let conn = seed();
1926 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1927 let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
1928 assert!(before > 0);
1929 delete_person(&conn, "ghost").unwrap();
1930 assert_eq!(
1931 videre_core::face_db::recluster_watermark(&conn).unwrap(),
1932 before,
1933 "a no-op delete must not reopen the gated regroup"
1934 );
1935 }
1936
1937 #[test]
1938 fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
1939 let conn = seed();
1946 assign(&conn, &[1, 2], "Alice").unwrap();
1947 assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
1948 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
1951 assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
1952
1953 delete_person(&conn, "Alice").unwrap();
1954 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
1955 assert_eq!(
1956 videre_core::face_db::recluster_watermark(&conn).unwrap(),
1957 0,
1958 "deleting a person must reopen the gated regroup for their faces"
1959 );
1960 let rows: Vec<(Option<i64>, Option<String>, i64)> = {
1961 let mut s = conn
1962 .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
1963 .unwrap();
1964 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1965 .unwrap()
1966 .collect::<rusqlite::Result<_>>()
1967 .unwrap()
1968 };
1969 assert!(
1970 rows.iter()
1971 .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
1972 "every face returns to the unassigned pool: {rows:?}"
1973 );
1974 }
1975
1976 #[test]
1977 fn set_primary_is_exclusive_per_person() {
1978 let conn = seed();
1979 set_primary(&conn, 2, "Alice").unwrap();
1980 let primaries: Vec<i64> = {
1981 let mut s = conn
1982 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
1983 .unwrap();
1984 s.query_map([], |r| r.get(0))
1985 .unwrap()
1986 .collect::<rusqlite::Result<_>>()
1987 .unwrap()
1988 };
1989 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
1990 }
1991
1992 #[test]
1993 fn renaming_only_the_spelling_keeps_the_identity() {
1994 let conn = seed();
1997 set_full_name(&conn, "alice", "Alice Smith").unwrap();
1998 let (name, full): (String, String) = conn
1999 .query_row("SELECT name, full_name FROM people", [], |r| {
2000 Ok((r.get(0)?, r.get(1)?))
2001 })
2002 .unwrap();
2003 assert_eq!(name, "alice", "identity is unchanged");
2004 assert_eq!(full, "Alice Smith", "only the display name moved");
2005 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
2006 }
2007
2008 #[test]
2015 fn assign_a_missing_face_is_not_found() {
2016 let conn = seed();
2017 assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
2018 }
2019
2020 #[test]
2021 fn assign_is_atomic_when_one_face_is_missing() {
2022 let conn = seed();
2026 assert!(matches!(
2027 assign(&conn, &[3, 999], "Bob"),
2028 Err(Error::NotFound)
2029 ));
2030 let (label, confirmed): (Option<String>, i64) = conn
2031 .query_row(
2032 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2033 [],
2034 |r| Ok((r.get(0)?, r.get(1)?)),
2035 )
2036 .unwrap();
2037 assert_eq!(label, None, "face 3 must not have been labelled");
2038 assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
2039 let bob: i64 = conn
2040 .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
2041 r.get(0)
2042 })
2043 .unwrap();
2044 assert_eq!(
2045 bob, 0,
2046 "no person may be created when the assign rolls back"
2047 );
2048 }
2049
2050 #[test]
2051 fn assign_commit_failure_rolls_back_and_closes_the_transaction() {
2052 let conn = seed();
2053 conn.execute_batch(
2054 "PRAGMA foreign_keys = ON;
2055 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
2056 CREATE TABLE commit_guard_child (
2057 face_id INTEGER PRIMARY KEY,
2058 parent_id INTEGER NOT NULL,
2059 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
2060 DEFERRABLE INITIALLY DEFERRED
2061 );
2062 CREATE TRIGGER fail_assign_commit
2063 AFTER UPDATE ON faces
2064 WHEN NEW.id = 3
2065 BEGIN
2066 INSERT INTO commit_guard_child (face_id, parent_id)
2067 VALUES (NEW.id, 999);
2068 END;",
2069 )
2070 .unwrap();
2071
2072 assert!(assign(&conn, &[3], "Bob").is_err());
2073 assert!(conn.is_autocommit());
2074 let state: (Option<String>, i64) = conn
2075 .query_row(
2076 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2077 [],
2078 |row| Ok((row.get(0)?, row.get(1)?)),
2079 )
2080 .unwrap();
2081 assert_eq!(state, (None, 0));
2082 let bob: i64 = conn
2083 .query_row(
2084 "SELECT COUNT(*) FROM people WHERE name = 'bob'",
2085 [],
2086 |row| row.get(0),
2087 )
2088 .unwrap();
2089 assert_eq!(bob, 0);
2090 }
2091
2092 #[test]
2093 fn assign_rejects_empty_face_ids() {
2094 let conn = seed();
2097 assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
2098 }
2099
2100 #[test]
2101 fn remove_face_missing_is_not_found() {
2102 let conn = seed();
2103 assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
2104 }
2105
2106 #[test]
2107 fn dissolve_cluster_missing_is_not_found() {
2108 let conn = seed();
2109 assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
2110 }
2111
2112 #[test]
2113 fn set_primary_missing_face_is_not_found() {
2114 let conn = seed();
2115 assert!(matches!(
2116 set_primary(&conn, 999, "Alice"),
2117 Err(Error::NotFound)
2118 ));
2119 }
2120
2121 #[test]
2122 fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
2123 let conn = seed();
2127 assert!(matches!(
2128 set_primary(&conn, 5, "Alice"),
2129 Err(Error::NotFound)
2130 ));
2131 let primary: i64 = conn
2132 .query_row(
2133 "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
2134 [],
2135 |r| r.get(0),
2136 )
2137 .unwrap();
2138 assert_eq!(
2139 primary, 1,
2140 "the original primary must be restored on rollback"
2141 );
2142 }
2143
2144 #[test]
2145 fn delete_person_missing_is_idempotent_success() {
2146 let conn = seed();
2152 assert!(delete_person(&conn, "Nobody").is_ok());
2153 }
2154}
2155
2156#[cfg(test)]
2157mod identity_tests {
2158 use super::tests::seed;
2159 use super::*;
2160
2161 fn people(conn: &Connection) -> Vec<(String, String)> {
2162 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
2163 .unwrap()
2164 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2165 .unwrap()
2166 .collect::<rusqlite::Result<_>>()
2167 .unwrap()
2168 }
2169
2170 #[test]
2171 fn assign_stores_the_identity_and_records_the_display_name() {
2172 let conn = seed();
2173 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
2174
2175 let label: String = conn
2176 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
2177 r.get(0)
2178 })
2179 .unwrap();
2180 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
2181 assert!(
2182 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
2183 "and the spelling is kept for display"
2184 );
2185 }
2186
2187 #[test]
2188 fn assigning_an_existing_name_in_another_case_joins_that_person() {
2189 let conn = seed();
2192 assign(&conn, &[3], "ALICE").unwrap();
2193 assert_eq!(people(&conn).len(), 1, "still one person, not two");
2194 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
2195 assert_eq!(
2196 people(&conn)[0].1,
2197 "Alice",
2198 "the existing spelling is not overwritten by the new casing"
2199 );
2200 }
2201
2202 #[test]
2203 fn assign_rejects_a_name_with_no_usable_identity() {
2204 let conn = seed();
2207 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
2208 }
2209
2210 #[test]
2211 fn person_detail_resolves_every_form_of_the_name() {
2212 let conn = seed();
2213 for form in ["alice", "Alice", "ALICE", " alice "] {
2214 assert_eq!(
2215 person_detail(&conn, form).unwrap().faces.len(),
2216 2,
2217 "form {form:?}"
2218 );
2219 }
2220 }
2221
2222 #[test]
2223 fn person_detail_reports_the_display_name() {
2224 let d = person_detail(&seed(), "alice").unwrap();
2225 assert_eq!(d.label, "alice");
2226 assert_eq!(d.full_name, "Alice");
2227 }
2228
2229 #[test]
2230 fn person_detail_falls_back_when_there_is_no_people_row() {
2231 let conn = seed();
2235 conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap();
2236 conn.execute(
2237 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
2238 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
2239 [],
2240 )
2241 .unwrap();
2242 conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
2243 let d = person_detail(&conn, "orphan").unwrap();
2244 assert_eq!(d.full_name, "orphan", "falls back to the identity");
2245 }
2246
2247 #[test]
2248 fn set_full_name_changes_only_the_display_name() {
2249 let conn = seed();
2250 set_full_name(&conn, "alice", "Alice Smith").unwrap();
2251 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
2252 assert_eq!(
2253 person_detail(&conn, "alice").unwrap().faces.len(),
2254 2,
2255 "no face was touched"
2256 );
2257 }
2258
2259 #[test]
2260 fn set_full_name_accepts_any_form_of_the_identity() {
2261 let conn = seed();
2262 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
2263 assert_eq!(people(&conn)[0].1, "Alice Smith");
2264 }
2265
2266 #[test]
2267 fn set_full_name_on_a_missing_person_is_not_found() {
2268 assert!(matches!(
2269 set_full_name(&seed(), "nobody", "Someone"),
2270 Err(Error::NotFound)
2271 ));
2272 }
2273
2274 #[test]
2275 fn set_full_name_rejects_an_empty_display_name() {
2276 assert!(matches!(
2278 set_full_name(&seed(), "alice", " "),
2279 Err(Error::Invalid)
2280 ));
2281 }
2282
2283 #[test]
2284 fn delete_person_accepts_any_form_of_the_name() {
2285 let conn = seed();
2286 delete_person(&conn, "Alice").unwrap();
2287 let left: i64 = conn
2288 .query_row(
2289 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
2290 [],
2291 |r| r.get(0),
2292 )
2293 .unwrap();
2294 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
2295 }
2296
2297 #[test]
2298 fn set_primary_accepts_any_form_of_the_name() {
2299 let conn = seed();
2300 set_primary(&conn, 2, "ALICE").unwrap();
2301 let primary: i64 = conn
2302 .query_row(
2303 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
2304 [],
2305 |r| r.get(0),
2306 )
2307 .unwrap();
2308 assert_eq!(primary, 2);
2309 }
2310}
2311
2312#[cfg(test)]
2313mod never_run_tests {
2314 use super::*;
2315
2316 #[test]
2328 fn a_library_that_never_ran_detection_is_empty_not_an_error() {
2329 let conn = Connection::open_in_memory().unwrap();
2330 conn.execute_batch(
2331 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
2332 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
2333 )
2334 .unwrap();
2335
2336 let data = faces_list(&conn).expect("a library with no faces table is not an error");
2337 assert!(data.people.is_empty());
2338 assert!(data.clusters.is_empty());
2339 assert!(data.singletons.is_empty());
2340 }
2341
2342 mod question_fixture {
2345 use super::*;
2346 use videre_core::face_learning::{
2347 ensure_question_tables, replace_pending_questions, select_questions, LogisticModel,
2348 LogisticScorer, ModelBundle, QuestionSelectionConfig, MEMBERSHIP_FEATURE_NAMES,
2349 MODEL_ARTIFACT_VERSION,
2350 };
2351
2352 pub fn embedding_blob(x: f32, y: f32) -> Vec<u8> {
2353 let mut bytes = Vec::with_capacity(4);
2354 bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
2355 bytes.extend_from_slice(&half::f16::from_f32(y).to_le_bytes());
2356 bytes
2357 }
2358
2359 fn logistic_bundle() -> ModelBundle {
2360 let names: Vec<String> = MEMBERSHIP_FEATURE_NAMES
2361 .iter()
2362 .map(|name| name.to_string())
2363 .collect();
2364 let means: Vec<f64> = names
2365 .iter()
2366 .map(|name| if name == "similarity_mean" { 1.0 } else { 0.0 })
2367 .collect();
2368 let scales: Vec<f64> = names
2369 .iter()
2370 .map(|name| if name == "similarity_mean" { 0.5 } else { 1.0 })
2371 .collect();
2372 let weights: Vec<f64> = names
2373 .iter()
2374 .map(|name| if name == "similarity_mean" { 2.0 } else { 0.0 })
2375 .collect();
2376 let scorer = LogisticScorer {
2377 model: LogisticModel {
2378 feature_names: names,
2379 means,
2380 scales,
2381 intercept: 0.0,
2382 weights,
2383 l2: 1.0,
2384 positive_class_weight: 1.0,
2385 },
2386 calibration: videre_core::face_learning::CalibrationModel {
2387 intercept: 0.0,
2388 slope: 1.0,
2389 },
2390 threshold: 0.5,
2391 };
2392 ModelBundle::Logistic {
2393 artifact_version: MODEL_ARTIFACT_VERSION,
2394 embedding_model_id: "arcface/test".into(),
2395 feature_schema_version: 1,
2396 membership: scorer.clone(),
2397 cluster_quality: scorer,
2398 }
2399 }
2400
2401 pub fn library() -> (Connection, i64, i64) {
2407 let conn = Connection::open_in_memory().unwrap();
2408 conn.execute_batch(
2409 "PRAGMA foreign_keys = ON;
2410 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
2411 CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
2412 bbox TEXT NOT NULL, landmark TEXT, embedding BLOB NOT NULL,
2413 cluster_id INTEGER,
2414 person_label TEXT REFERENCES people(name) ON DELETE RESTRICT ON UPDATE RESTRICT,
2415 confirmed INTEGER DEFAULT 0,
2416 is_primary INTEGER DEFAULT 0, det_score REAL, blur REAL, oriented INTEGER);",
2417 )
2418 .unwrap();
2419 videre_core::face_learning::ensure_learning_tables(&conn).unwrap();
2420 videre_core::face_learning::ensure_profile_table(&conn).unwrap();
2421 ensure_question_tables(&conn).unwrap();
2422
2423 for (id, cluster) in [(10, Some(1)), (11, Some(1)), (12, None), (13, None)] {
2424 conn.execute(
2425 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2426 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, 0.9, 600.0)",
2427 rusqlite::params![id, embedding_blob(1.0, 0.0), cluster],
2428 )
2429 .unwrap();
2430 }
2431 assign(&conn, &[12, 13], "Alice").unwrap();
2432
2433 let evidence =
2434 serde_json::to_string(&videre_core::face_learning::TrainingEvidenceCounts {
2435 positive_pairs: 20,
2436 negative_pairs: 20,
2437 explicit_negative_pairs: 0,
2438 })
2439 .unwrap();
2440 let report = serde_json::to_string(&videre_core::face_learning::ValidationReport {
2441 protocol_version: 1,
2442 evidence_schema_version: 1,
2443 feature_schema_version: 1,
2444 datasets: Vec::new(),
2445 })
2446 .unwrap();
2447 conn.execute(
2448 "INSERT INTO face_learning_profiles (
2449 artifact_version, embedding_model_id, feature_schema_version, model_kind,
2450 parameters, training_evidence_json, validation_report_json, stage, status
2451 ) VALUES (1, 'arcface/test', 1, 'logistic', ?1, ?2, ?3, 'suggestion', 'active')",
2452 rusqlite::params![
2453 serde_json::to_vec(&logistic_bundle()).unwrap(),
2454 evidence,
2455 report
2456 ],
2457 )
2458 .unwrap();
2459 let profile_id = conn.last_insert_rowid();
2460
2461 let candidates = select_questions(&conn, &QuestionSelectionConfig::default()).unwrap();
2462 assert_eq!(candidates.len(), 1, "fixture must produce one question");
2463 let stored = replace_pending_questions(&conn, &candidates).unwrap();
2464 assert_eq!(stored.len(), 1);
2465 (conn, stored[0].id, profile_id)
2466 }
2467
2468 pub fn stub_evidence() -> videre_core::face_learning::DecisionEvidence {
2469 use videre_core::face_learning::{
2470 Calibration, DecisionKind, DecisionOutcome, DecisionTarget, FeatureContribution,
2471 ValidationSummary, EVIDENCE_SCHEMA_VERSION, FEATURE_SCHEMA_VERSION,
2472 };
2473 let evidence = videre_core::face_learning::DecisionEvidence {
2474 schema_version: EVIDENCE_SCHEMA_VERSION,
2475 profile_id: 1,
2476 feature_schema_version: FEATURE_SCHEMA_VERSION,
2477 decision_kind: DecisionKind::Membership,
2478 outcome: DecisionOutcome::Allowed,
2479 subject_face_ids: vec![10],
2480 target: DecisionTarget::Person("alice".into()),
2481 intercept: 0.0,
2482 raw_logit: 0.0,
2483 calibration: Calibration {
2484 intercept: 0.0,
2485 slope: 1.0,
2486 },
2487 calibrated_confidence: 0.5,
2488 threshold: 0.5,
2489 margin: 0.0,
2490 features: vec![FeatureContribution {
2491 name: "similarity_mean".into(),
2492 value: 1.0,
2493 contribution: 0.0,
2494 }],
2495 support_face_ids: vec![12, 13],
2496 rule_vetoes: Vec::new(),
2497 validation: ValidationSummary {
2498 protocol_version: 1,
2499 datasets: 1,
2500 pair_precision: None,
2501 pair_recall: None,
2502 suggestion_precision: None,
2503 suggestion_coverage: None,
2504 },
2505 };
2506 evidence.validate().unwrap();
2507 evidence
2508 }
2509
2510 pub fn context(profile_id: i64) -> TeachingContext {
2511 TeachingContext {
2512 embedding_model_id: "arcface/test".into(),
2513 active_profile_id: Some(profile_id),
2514 }
2515 }
2516 }
2517
2518 use question_fixture as qf;
2519
2520 #[test]
2521 fn deleting_a_person_supersedes_questions_and_advances_once() {
2522 let (conn, _question_id, _profile_id) = qf::library();
2523 let second = videre_core::face_learning::StoredQuestion {
2525 id: 999,
2526 status: videre_core::face_learning::QuestionStatus::Pending,
2527 subject_face_ids: vec![10],
2528 support_face_ids: vec![12, 13],
2529 target_identity: "alice".into(),
2530 target_display: "Alice".into(),
2531 profile_id: 1,
2532 model_kind: "logistic".into(),
2533 representative_face_id: 10,
2534 cluster_id: 1,
2535 evidence_revision: "another-revision".into(),
2536 evidence: qf::stub_evidence(),
2537 created_at: "2026-01-01 00:00:00".into(),
2538 decided_at: None,
2539 };
2540 let _ = second;
2541 delete_person_with_learning(&conn, "Alice").unwrap();
2542 let superseded: i64 = conn
2543 .query_row(
2544 "SELECT count(*) FROM face_learning_questions WHERE status = 'superseded'",
2545 [],
2546 |row| row.get(0),
2547 )
2548 .unwrap();
2549 assert_eq!(superseded, 1, "the pending question must be superseded");
2550 let state = learning_state(&conn).unwrap();
2551 assert_eq!(state.generation, 1, "exactly one generation advance");
2552 let invalidated: i64 = conn
2553 .query_row(
2554 "SELECT count(*) FROM face_learning_events WHERE eligible = 0",
2555 [],
2556 |row| row.get(0),
2557 )
2558 .unwrap();
2559 assert_eq!(invalidated, 0, "no events existed to invalidate");
2560 }
2561
2562 #[test]
2563 fn the_journal_reports_availability_without_rewriting_history() {
2564 let (conn, _question_id, profile_id) = qf::library();
2565 assign_with_learning(&conn, &[10, 11], "Alice", &qf::context(profile_id)).unwrap();
2567 let subject_event_id = face_learning_events(&conn, 50, None, Some("arcface/test"))
2568 .unwrap()
2569 .iter()
2570 .find(|proof| proof.event.faces.iter().any(|face| face.face_id == 10))
2571 .map(|proof| proof.event.id)
2572 .unwrap();
2573 conn.execute("DELETE FROM faces WHERE id = 10", []).unwrap();
2576
2577 let proofs = face_learning_events(&conn, 50, None, Some("arcface/test")).unwrap();
2578 let proof = proofs
2579 .iter()
2580 .find(|proof| proof.event.id == subject_event_id)
2581 .unwrap();
2582 assert!(!proof.source_available, "the subject face is gone");
2583 assert!(!proof.incompatible, "same model and schema stay usable");
2584 assert!(proof.event.eligible, "missing provenance stays eligible");
2585
2586 let proofs = face_learning_events(&conn, 50, None, Some("other/model")).unwrap();
2588 let proof = proofs
2589 .iter()
2590 .find(|proof| proof.event.id == subject_event_id)
2591 .unwrap();
2592 assert!(proof.incompatible);
2593
2594 let (conn, question_id, profile_id) = qf::library();
2596 answer_question_with_learning(
2597 &conn,
2598 question_id,
2599 videre_core::face_learning::QuestionAnswer::No,
2600 &qf::context(profile_id),
2601 )
2602 .unwrap();
2603 let before: String = conn
2604 .query_row(
2605 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2606 [],
2607 |row| row.get(0),
2608 )
2609 .unwrap();
2610 delete_person_with_learning(&conn, "Alice").unwrap();
2611 let after: String = conn
2612 .query_row(
2613 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2614 [],
2615 |row| row.get(0),
2616 )
2617 .unwrap();
2618 assert_eq!(before, after, "historical feature JSON never mutates");
2619 }
2620
2621 #[test]
2622 fn yes_confirms_the_target_and_teaches_positive_membership() {
2623 let (conn, question_id, profile_id) = qf::library();
2624 let outcome = answer_question_with_learning(
2625 &conn,
2626 question_id,
2627 QuestionAnswer::Yes,
2628 &qf::context(profile_id),
2629 )
2630 .unwrap();
2631 assert_eq!(outcome.status, "answered");
2632 let ack = outcome.acknowledgement.expect("yes must teach");
2633 assert_eq!(ack.event_ids.len(), 1);
2634 assert_eq!(ack.generation, 1);
2635
2636 let labeled: i64 = conn
2637 .query_row(
2638 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND person_label = 'alice'
2639 AND confirmed = 1 AND cluster_id IS NULL",
2640 [],
2641 |row| row.get(0),
2642 )
2643 .unwrap();
2644 assert_eq!(labeled, 2, "yes labels the whole subject cluster");
2645
2646 let event: (String, String, String) = conn
2647 .query_row(
2648 "SELECT action_kind, outcome, target_identity FROM face_learning_events",
2649 [],
2650 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2651 )
2652 .unwrap();
2653 assert_eq!(event.0, "question_yes");
2654 assert_eq!(event.1, "positive");
2655 assert_eq!(event.2, "alice");
2656 }
2657
2658 #[test]
2659 fn no_teaches_negative_without_labeling() {
2660 let (conn, question_id, profile_id) = qf::library();
2661 let outcome = answer_question_with_learning(
2662 &conn,
2663 question_id,
2664 QuestionAnswer::No,
2665 &qf::context(profile_id),
2666 )
2667 .unwrap();
2668 assert_eq!(outcome.status, "answered");
2669
2670 let untouched: i64 = conn
2671 .query_row(
2672 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 0
2673 AND person_label IS NULL AND cluster_id = 1",
2674 [],
2675 |row| row.get(0),
2676 )
2677 .unwrap();
2678 assert_eq!(untouched, 2, "no must not label");
2679
2680 let event: (String, String) = conn
2681 .query_row(
2682 "SELECT action_kind, outcome FROM face_learning_events",
2683 [],
2684 |row| Ok((row.get(0)?, row.get(1)?)),
2685 )
2686 .unwrap();
2687 assert_eq!(event.0, "question_no");
2688 assert_eq!(event.1, "negative");
2689 }
2690
2691 #[test]
2692 fn skip_only_changes_delivery_state() {
2693 let (conn, question_id, profile_id) = qf::library();
2694 let outcome = answer_question_with_learning(
2695 &conn,
2696 question_id,
2697 QuestionAnswer::Skip,
2698 &qf::context(profile_id),
2699 )
2700 .unwrap();
2701 assert_eq!(outcome.status, "skipped");
2702 assert!(outcome.acknowledgement.is_none());
2703
2704 let events: i64 = conn
2705 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2706 row.get(0)
2707 })
2708 .unwrap();
2709 assert_eq!(events, 0, "skip produces no event");
2710 let state = learning_state(&conn).unwrap();
2711 assert_eq!(state.generation, 0, "skip does not advance generation");
2712 }
2713
2714 #[test]
2715 fn stale_answers_conflict_without_partial_writes() {
2716 let (conn, question_id, profile_id) = qf::library();
2718 assign(&conn, &[10, 11], "Bob").unwrap();
2719 assert!(matches!(
2720 answer_question_with_learning(
2721 &conn,
2722 question_id,
2723 QuestionAnswer::Yes,
2724 &qf::context(profile_id)
2725 ),
2726 Err(Error::Conflict)
2727 ));
2728 let events: i64 = conn
2729 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2730 row.get(0)
2731 })
2732 .unwrap();
2733 assert_eq!(events, 0, "a conflict must not teach");
2734 assert_eq!(
2735 videre_core::face_learning::stored_question(&conn, question_id)
2736 .unwrap()
2737 .unwrap()
2738 .status,
2739 QuestionStatus::Superseded
2740 );
2741
2742 let (conn, question_id, profile_id) = qf::library();
2745 conn.execute_batch(
2746 "UPDATE faces SET person_label = NULL, confirmed = 0 WHERE person_label = 'alice';
2747 DELETE FROM people WHERE name = 'alice';",
2748 )
2749 .unwrap();
2750 assert!(matches!(
2751 answer_question_with_learning(
2752 &conn,
2753 question_id,
2754 QuestionAnswer::No,
2755 &qf::context(profile_id)
2756 ),
2757 Err(Error::Conflict)
2758 ));
2759 assert_eq!(
2760 videre_core::face_learning::stored_question(&conn, question_id)
2761 .unwrap()
2762 .unwrap()
2763 .status,
2764 QuestionStatus::Superseded
2765 );
2766
2767 let (conn, question_id, profile_id) = qf::library();
2769 conn.execute("UPDATE face_learning_profiles SET status = 'retired'", [])
2770 .unwrap();
2771 let _ = profile_id;
2772 assert!(matches!(
2773 answer_question_with_learning(&conn, question_id, QuestionAnswer::No, &qf::context(99)),
2774 Err(Error::Conflict)
2775 ));
2776 assert_eq!(
2777 videre_core::face_learning::stored_question(&conn, question_id)
2778 .unwrap()
2779 .unwrap()
2780 .status,
2781 QuestionStatus::Superseded
2782 );
2783
2784 let (conn, question_id, profile_id) = qf::library();
2786 assign(&conn, &[13], "Alice").unwrap();
2787 remove_face(&conn, 12).unwrap();
2788 insert_face_with_score(&conn, 14, None, 0.9);
2789 assign(&conn, &[14], "Alice").unwrap();
2790 assert!(matches!(
2791 answer_question_with_learning(
2792 &conn,
2793 question_id,
2794 QuestionAnswer::No,
2795 &qf::context(profile_id)
2796 ),
2797 Err(Error::Conflict)
2798 ));
2799 let question = videre_core::face_learning::stored_question(&conn, question_id)
2800 .unwrap()
2801 .unwrap();
2802 assert_eq!(
2803 question.status,
2804 videre_core::face_learning::QuestionStatus::Superseded
2805 );
2806 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2807 }
2808
2809 fn insert_face_with_score(conn: &Connection, id: i64, cluster: Option<i64>, score: f64) {
2810 conn.execute(
2811 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2812 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, ?4, 600.0)",
2813 rusqlite::params![id, qf::embedding_blob(1.0, 0.0), cluster, score],
2814 )
2815 .unwrap();
2816 }
2817
2818 #[test]
2819 fn faces_moved_out_of_the_question_cluster_conflict() {
2820 let (conn, question_id, profile_id) = qf::library();
2821 conn.execute("UPDATE faces SET cluster_id = 9 WHERE id = 11", [])
2824 .unwrap();
2825 assert!(matches!(
2826 answer_question_with_learning(
2827 &conn,
2828 question_id,
2829 QuestionAnswer::Yes,
2830 &qf::context(profile_id)
2831 ),
2832 Err(Error::Conflict)
2833 ));
2834
2835 let labeled: i64 = conn
2836 .query_row(
2837 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
2838 [],
2839 |row| row.get(0),
2840 )
2841 .unwrap();
2842 assert_eq!(labeled, 0, "a stale cluster must not label");
2843 let events: i64 = conn
2844 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2845 row.get(0)
2846 })
2847 .unwrap();
2848 assert_eq!(events, 0);
2849 let question = videre_core::face_learning::stored_question(&conn, question_id)
2850 .unwrap()
2851 .unwrap();
2852 assert_eq!(
2853 question.status,
2854 videre_core::face_learning::QuestionStatus::Superseded
2855 );
2856 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2857 }
2858
2859 #[test]
2860 fn refresh_creates_question_tables_for_a_first_training_cycle() {
2861 let (conn, _, _) = qf::library();
2862 conn.execute_batch(
2863 "DROP TABLE face_learning_question_faces;
2864 DROP TABLE face_learning_questions;",
2865 )
2866 .unwrap();
2867
2868 let questions = refresh_identity_questions(&conn, &QuestionSelectionConfig::default())
2869 .expect("a promoted profile should create the question tables");
2870 assert_eq!(questions.len(), 1);
2871 assert_eq!(pending_identity_questions(&conn, 5).unwrap().len(), 1);
2872 }
2873
2874 #[test]
2878 fn v2_library_enforces_keys_through_the_public_paths() {
2879 use videre_core::face_learning::QuestionAnswer as Answer;
2880 let root = tempfile::tempdir().unwrap();
2881 let cache = tempfile::tempdir().unwrap();
2882 let ctx = videre_core::library::LibraryContext::new(root.path(), cache.path()).unwrap();
2883 let conn = videre_core::library_db::initialize(&ctx).unwrap();
2884 let keys_on: i64 = conn
2885 .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
2886 .unwrap();
2887 assert_eq!(keys_on, 1, "an initialized library verifies enforcement");
2888
2889 conn.execute_batch(
2892 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur) VALUES
2893 (1, 'k1', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0),
2894 (2, 'k2', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0);
2895 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice'), ('bob', 'Bob');",
2896 )
2897 .unwrap();
2898 assign(&conn, &[1], "Alice").unwrap();
2899 assign(&conn, &[2], "Bob").unwrap();
2900
2901 assert!(conn
2904 .execute(
2905 "INSERT INTO faces (hash,bbox,embedding,person_label,confirmed)
2906 VALUES ('k9','0,0,9,9',X'0000','ghost',1)",
2907 [],
2908 )
2909 .is_err());
2910 assert!(conn
2911 .execute(
2912 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2913 VALUES (999, 1, 'subject', 0)",
2914 [],
2915 )
2916 .is_err());
2917
2918 conn.execute(
2920 "INSERT INTO face_learning_events (id, action_kind, decision_kind, outcome,
2921 embedding_model_id, feature_schema_version, target_identity,
2922 feature_snapshot_json, support_count)
2923 VALUES (1, 'assign_face', 'membership', 'positive', 'x/1', 1, 'alice', '{}', 0)",
2924 [],
2925 )
2926 .unwrap();
2927 conn.execute(
2928 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
2929 VALUES (1, 1, 'subject', 0)",
2930 [],
2931 )
2932 .unwrap();
2933
2934 delete_person_with_learning(&conn, "Alice").unwrap();
2937 let state: (i64, Option<String>) = conn
2938 .query_row(
2939 "SELECT confirmed, person_label FROM faces WHERE id = 1",
2940 [],
2941 |r| Ok((r.get(0)?, r.get(1)?)),
2942 )
2943 .unwrap();
2944 assert_eq!(state, (0, None));
2945
2946 let question = videre_core::face_learning::select_questions(
2948 &conn,
2949 &videre_core::face_learning::QuestionSelectionConfig::default(),
2950 )
2951 .unwrap();
2952 if !question.is_empty() {
2953 let stored =
2954 videre_core::face_learning::replace_pending_questions(&conn, &question).unwrap();
2955 conn.execute(
2956 "UPDATE face_learning_questions SET evidence_revision = 'stale' WHERE id = ?1",
2957 rusqlite::params![stored[0].id],
2958 )
2959 .unwrap();
2960 let context = TeachingContext {
2961 embedding_model_id: "x/1".into(),
2962 active_profile_id: None,
2963 };
2964 assert!(matches!(
2965 answer_question_with_learning(&conn, stored[0].id, Answer::Yes, &context),
2966 Err(Error::Conflict)
2967 ));
2968 }
2969
2970 videre_core::face_db::reset_all(&conn).unwrap();
2972 for table in [
2973 "face_learning_events",
2974 "face_learning_event_faces",
2975 "face_learning_questions",
2976 "face_learning_question_faces",
2977 "face_learning_profiles",
2978 ] {
2979 let n: i64 = conn
2980 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
2981 .unwrap();
2982 assert_eq!(n, 0, "{table} must be empty after reset");
2983 }
2984 let violations: i64 = conn
2985 .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
2986 r.get(0)
2987 })
2988 .unwrap();
2989 assert_eq!(violations, 0);
2990 }
2991
2992 #[test]
2993 fn yes_cannot_label_without_evidence() {
2994 let (conn, question_id, profile_id) = qf::library();
2995 conn.execute_batch(
2996 "CREATE TRIGGER abort_question_events
2997 BEFORE INSERT ON face_learning_events
2998 BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;",
2999 )
3000 .unwrap();
3001 assert!(answer_question_with_learning(
3002 &conn,
3003 question_id,
3004 QuestionAnswer::Yes,
3005 &qf::context(profile_id)
3006 )
3007 .is_err());
3008
3009 let labeled: i64 = conn
3010 .query_row(
3011 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
3012 [],
3013 |row| row.get(0),
3014 )
3015 .unwrap();
3016 assert_eq!(labeled, 0, "yes cannot label without its evidence row");
3017
3018 let question = videre_core::face_learning::stored_question(&conn, question_id)
3019 .unwrap()
3020 .unwrap();
3021 assert_eq!(
3022 question.status,
3023 videre_core::face_learning::QuestionStatus::Pending
3024 );
3025 }
3026}