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 || 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 videre_core::face_learning::ensure_profile_table(conn)?;
1069 let active_profile = conn
1072 .query_row(
1073 "SELECT id, stage FROM face_learning_profiles WHERE status = 'active' LIMIT 1",
1074 [],
1075 |row| {
1076 Ok(ActiveProfile {
1077 profile_id: row.get(0)?,
1078 stage: row.get(1)?,
1079 })
1080 },
1081 )
1082 .optional()?;
1083 let feedback_needed = state.feedback_needed.filter(|_| waiting);
1084 let summary = learning_summary(
1085 conn,
1086 active_profile.as_ref(),
1087 feedback_needed.as_deref(),
1088 failed,
1089 )?;
1090 Ok(FaceLearningStatus {
1091 generation: state.generation,
1092 trained_generation: state.trained_generation,
1093 status: format!("{:?}", state.status).to_lowercase(),
1094 last_profile_id: state.last_profile_id,
1095 last_candidate,
1096 last_error: state.last_error.filter(|_| failed),
1099 feedback_needed,
1100 pending_questions: pending_questions as usize,
1101 active_profile,
1102 summary,
1103 })
1104}
1105
1106fn learning_summary(
1109 conn: &Connection,
1110 active: Option<&ActiveProfile>,
1111 feedback_needed: Option<&str>,
1112 failed: bool,
1113) -> Result<String> {
1114 if let Some(active) = active {
1115 return Ok(format!(
1116 "Learning: profile {} suggests names; grouping uses the settings above.",
1117 active.profile_id
1118 ));
1119 }
1120 if let Some(needed) = feedback_needed {
1121 return Ok(format!("Learning: not used yet; {needed}."));
1122 }
1123 let rejected: i64 = conn.query_row(
1124 "SELECT count(*) FROM face_learning_profiles WHERE status = 'rejected'",
1125 [],
1126 |row| row.get(0),
1127 )?;
1128 if rejected > 0 {
1129 let latest: i64 = conn.query_row(
1130 "SELECT max(id) FROM face_learning_profiles WHERE status = 'rejected'",
1131 [],
1132 |row| row.get(0),
1133 )?;
1134 let reason = rejection_reason(conn, latest)?
1135 .map(|r| format!(" ({r})"))
1136 .unwrap_or_default();
1137 return Ok(format!(
1138 "Learning: not used yet; {rejected} trained candidate(s) did not pass the quality checks{reason}. More confirmed names help."
1139 ));
1140 }
1141 if failed {
1142 return Ok(
1143 "Learning: not used yet; the last training run failed and retries after new feedback."
1144 .into(),
1145 );
1146 }
1147 Ok("Learning: not used yet; naming people teaches it.".into())
1148}
1149
1150pub fn rejection_reason(conn: &Connection, profile_id: i64) -> Result<Option<String>> {
1152 let json: Option<String> = conn
1153 .query_row(
1154 "SELECT promotion_result_json FROM face_learning_profiles WHERE id = ?1",
1155 [profile_id],
1156 |row| row.get(0),
1157 )
1158 .optional()?
1159 .flatten();
1160 Ok(json
1161 .and_then(|json| {
1162 serde_json::from_str::<Vec<videre_core::face_learning::GateFailure>>(&json).ok()
1163 })
1164 .and_then(|failures| failures.first().map(describe_gate_failure)))
1165}
1166
1167fn describe_gate_failure(failure: &videre_core::face_learning::GateFailure) -> String {
1170 let gate = failure.gate.replace('_', " ");
1171 match (failure.observed, failure.required) {
1172 (Some(observed), Some(required)) => {
1173 format!("{gate} {observed:.2}, needs {required:.2}")
1174 }
1175 _ => gate,
1176 }
1177}
1178
1179#[derive(Debug, Clone, serde::Serialize)]
1184pub struct FaceLearningEventProof {
1185 #[serde(flatten)]
1186 pub event: videre_core::face_learning::StoredLearningEvent,
1187 pub source_available: bool,
1188 pub incompatible: bool,
1189}
1190
1191fn proof_for(
1192 conn: &Connection,
1193 event: videre_core::face_learning::StoredLearningEvent,
1194 current_embedding_model_id: Option<&str>,
1195) -> Result<FaceLearningEventProof> {
1196 let mut source_available = true;
1197 for face in &event.faces {
1198 let exists: bool = conn.query_row(
1199 "SELECT EXISTS(SELECT 1 FROM faces WHERE id = ?1)",
1200 [face.face_id],
1201 |row| row.get(0),
1202 )?;
1203 if !exists {
1204 source_available = false;
1205 break;
1206 }
1207 }
1208 let incompatible = event.features.schema_version
1209 != videre_core::face_learning::FEATURE_SCHEMA_VERSION
1210 || current_embedding_model_id.is_some_and(|model| model != event.embedding_model_id);
1211 Ok(FaceLearningEventProof {
1212 event,
1213 source_available,
1214 incompatible,
1215 })
1216}
1217
1218pub fn face_learning_events(
1221 conn: &Connection,
1222 limit: usize,
1223 before_id: Option<i64>,
1224 current_embedding_model_id: Option<&str>,
1225) -> Result<Vec<FaceLearningEventProof>> {
1226 videre_core::face_learning::ensure_learning_tables(conn)?;
1227 let limit = limit.clamp(1, 200);
1228 let events = list_learning_events(conn, limit, before_id)?;
1229 events
1230 .into_iter()
1231 .map(|event| proof_for(conn, event, current_embedding_model_id))
1232 .collect()
1233}
1234
1235pub fn face_learning_event(
1236 conn: &Connection,
1237 event_id: i64,
1238 current_embedding_model_id: Option<&str>,
1239) -> Result<Option<FaceLearningEventProof>> {
1240 videre_core::face_learning::ensure_learning_tables(conn)?;
1241 match videre_core::face_learning::learning_event(conn, event_id)? {
1242 Some(event) => Ok(Some(proof_for(conn, event, current_embedding_model_id)?)),
1243 None => Ok(None),
1244 }
1245}
1246
1247pub fn load_training_snapshot(
1249 conn: &Connection,
1250 embedding_model_id: &str,
1251 generation: u64,
1252 config: &videre_core::face_learning::TrainingConfig,
1253) -> std::result::Result<videre_core::face_learning::TrainingSnapshot, String> {
1254 let labels =
1255 videre_core::face_db::load_confirmed_face_labels(conn).map_err(|e| e.to_string())?;
1256 let face_ids: Vec<i64> = {
1257 let mut statement = conn
1258 .prepare("SELECT id FROM faces ORDER BY id")
1259 .map_err(|e| e.to_string())?;
1260 let rows = statement
1261 .query_map([], |row| row.get(0))
1262 .map_err(|e| e.to_string())?
1263 .collect::<rusqlite::Result<Vec<i64>>>()
1264 .map_err(|e| e.to_string())?;
1265 rows
1266 };
1267 let observations =
1268 videre_core::face_db::load_face_observations(conn, &face_ids).map_err(|e| e.to_string())?;
1269 let events = videre_core::face_learning::eligible_events_for_training(
1270 conn,
1271 embedding_model_id,
1272 videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1273 )
1274 .map_err(|e| e.to_string())?;
1275 videre_core::face_learning::build_training_snapshot(
1276 generation,
1277 embedding_model_id,
1278 &labels,
1279 &observations,
1280 &events,
1281 config,
1282 )
1283 .map_err(|e| e.to_string())
1284}
1285
1286pub fn persist_trained_profile(
1290 conn: &Connection,
1291 embedding_model_id: &str,
1292 run: &videre_core::face_learning::TrainingRun,
1293 gates: &videre_core::face_learning::PromotionGates,
1294) -> Result<TrainedProfileSummary> {
1295 let validation = match run.comparison.selected {
1296 videre_core::face_learning::CandidateKind::Logistic => &run.logistic_validation,
1297 videre_core::face_learning::CandidateKind::Additive => &run.additive_validation,
1298 };
1299 let profile = videre_core::face_learning::NewProfile {
1300 artifact_version: videre_core::face_learning::PROFILE_ARTIFACT_VERSION,
1301 embedding_model_id: embedding_model_id.to_owned(),
1302 feature_schema_version: videre_core::face_learning::FEATURE_SCHEMA_VERSION,
1303 model_kind: run.selected.model_kind().to_owned(),
1304 parameters: serde_json::to_vec(&run.selected).map_err(Error::from)?,
1305 training_evidence: run.evidence_counts.clone(),
1306 validation_report: validation.clone(),
1307 stage: videre_core::face_learning::ProfileStage::Suggestion,
1308 };
1309 let profile_id = videre_core::face_learning::insert_candidate(conn, &profile)?;
1310 let outcome = videre_core::face_learning::evaluate_and_promote(conn, profile_id, gates)?;
1311 Ok(TrainedProfileSummary {
1312 profile_id,
1313 model_kind: profile.model_kind,
1314 promoted: outcome == videre_core::face_learning::PromotionOutcome::Promoted,
1315 })
1316}
1317
1318pub fn set_primary(conn: &Connection, face_id: i64, person_label: &str) -> Result<()> {
1323 let person_label =
1324 videre_core::person::normalize(person_label).unwrap_or_else(|| person_label.to_string());
1325 conn.execute_batch("BEGIN")?;
1326 let result = (|| -> Result<()> {
1327 conn.execute(
1328 "UPDATE faces SET is_primary = 0 WHERE person_label = ?1",
1329 rusqlite::params![person_label],
1330 )?;
1331 let n = conn.execute(
1336 "UPDATE faces SET is_primary = 1, confirmed = 1, person_label = ?1 WHERE id = ?2 AND person_label = ?1",
1337 rusqlite::params![person_label, face_id],
1338 )?;
1339 if n == 0 {
1340 return Err(Error::NotFound);
1341 }
1342 Ok(())
1343 })();
1344 match result {
1345 Ok(()) => {
1346 conn.execute_batch("COMMIT")?;
1347 Ok(())
1348 }
1349 Err(e) => {
1350 let _ = conn.execute_batch("ROLLBACK");
1351 Err(e)
1352 }
1353 }
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 use super::*;
1359
1360 #[test]
1361 fn assign_detaches_the_face_from_its_cluster() {
1362 let conn = seed();
1363 assign(&conn, &[3], "Bob").unwrap();
1366 let (label, confirmed, cid): (Option<String>, i64, Option<i64>) = conn
1367 .query_row(
1368 "SELECT person_label, confirmed, cluster_id FROM faces WHERE id = 3",
1369 [],
1370 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1371 )
1372 .unwrap();
1373 assert_eq!(label.as_deref(), Some("bob"));
1374 assert_eq!(confirmed, 1);
1375 assert_eq!(cid, None, "assignment must detach the machine grouping");
1376 }
1377
1378 #[test]
1379 fn cluster_detail_never_shows_labeled_faces() {
1380 let conn = seed();
1381 conn.execute(
1385 "INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed) VALUES
1386 (11,'h6','0,0,9,9',X'0000',7,'alice',1)",
1387 [],
1388 )
1389 .unwrap();
1390 conn.execute(
1391 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg')",
1392 [],
1393 )
1394 .unwrap();
1395 let detail = cluster_detail(&conn, 7).unwrap();
1396 assert_eq!(
1397 detail.faces.len(),
1398 2,
1399 "only the unlabeled faces of cluster 7 belong on the page"
1400 );
1401 }
1402
1403 pub(super) fn seed() -> Connection {
1410 let conn = Connection::open_in_memory().unwrap();
1411 videre_core::face_db::create_faces_table(&conn).unwrap();
1412 conn.execute_batch(
1413 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1414 INSERT INTO file_hashes VALUES ('h1','/p/1.jpg'),('h2','/p/2.jpg'),
1415 ('h3','/p/3.jpg'),('h4','/p/4.jpg'),('h5','/p/5.jpg');
1416 -- Labels are stored in identity form, as `assign` writes them and
1417 -- as the migration leaves them; `people` carries what a reader
1418 -- sees. Seeding raw 'Alice' would test a state the application no
1419 -- longer produces.
1420 INSERT INTO people (name, full_name) VALUES ('alice','Alice');
1421 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1422 (1,'h1','0,0,9,9',X'0000',NULL,'alice',1,1),
1423 (2,'h2','0,0,9,9',X'0000',NULL,'alice',1,0),
1424 (3,'h3','0,0,9,9',X'0000',7,NULL,0,0),
1425 (4,'h4','0,0,9,9',X'0000',7,NULL,0,0),
1426 (5,'h5','0,0,9,9',X'0000',NULL,NULL,0,0);",
1427 )
1428 .unwrap();
1429 videre_core::library_db::ensure_scan_schema(&conn).unwrap();
1430 conn
1431 }
1432
1433 mod learning {
1434 use super::*;
1435 use videre_core::face_learning::{
1436 learning_state, list_learning_events, LearningAction, LearningDecisionKind,
1437 LearningOutcome,
1438 };
1439
1440 fn context() -> TeachingContext {
1441 TeachingContext {
1442 embedding_model_id: "buffalo_l/w600k_r50.onnx".to_owned(),
1443 active_profile_id: None,
1444 }
1445 }
1446
1447 fn embedding(x: u16, y: u16) -> Vec<u8> {
1448 [x.to_le_bytes(), y.to_le_bytes()].concat()
1449 }
1450
1451 fn learning_seed() -> Connection {
1452 let conn = Connection::open_in_memory().unwrap();
1453 videre_core::face_db::create_faces_table(&conn).unwrap();
1454 conn.execute_batch(
1455 "CREATE TABLE file_hashes (hash TEXT PRIMARY KEY, path TEXT);
1456 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice');",
1457 )
1458 .unwrap();
1459 let rows = [
1460 (1, "a1", embedding(0x3c00, 0), None, Some("alice"), 1),
1461 (2, "a2", embedding(0x3b9a, 0x3266), None, Some("alice"), 1),
1462 (3, "c1", embedding(0x3c00, 0), Some(7), None, 0),
1463 (4, "c2", embedding(0x3b9a, 0x3266), Some(7), None, 0),
1464 (5, "c3", embedding(0x3b33, 0x34cd), Some(7), None, 0),
1465 (6, "s1", embedding(0x3266, 0x3b9a), None, None, 0),
1466 (7, "d1", embedding(0x3c00, 0), Some(9), None, 0),
1467 (8, "d2", embedding(0, 0x3c00), Some(9), None, 0),
1468 ];
1469 for (id, hash, bytes, cluster, label, confirmed) in rows {
1470 conn.execute(
1471 "INSERT INTO file_hashes (hash, path) VALUES (?1, ?2)",
1472 rusqlite::params![hash, format!("/p/{hash}.jpg")],
1473 )
1474 .unwrap();
1475 conn.execute(
1476 "INSERT INTO faces
1477 (id, hash, bbox, embedding, cluster_id, person_label, confirmed,
1478 is_primary, det_score, blur)
1479 VALUES (?1, ?2, '0,0,112,112', ?3, ?4, ?5, ?6, 0, 0.95, 900.0)",
1480 rusqlite::params![id, hash, bytes, cluster, label, confirmed],
1481 )
1482 .unwrap();
1483 }
1484 conn
1485 }
1486
1487 #[test]
1488 fn learning_assignments_emit_expected_positive_evidence_once_per_action() {
1489 let conn = learning_seed();
1490
1491 let assigned = assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1492 assert_eq!(assigned.generation, 1);
1493 assert_eq!(assigned.event_ids.len(), 1);
1494
1495 let labeled = new_person_with_learning(&conn, &[3, 4, 5], "Bob", &context()).unwrap();
1496 assert_eq!(labeled.generation, 2);
1497 assert_eq!(labeled.event_ids.len(), 4);
1498
1499 let events = list_learning_events(&conn, 20, None).unwrap();
1500 assert_eq!(events.len(), 5);
1501 assert_eq!(
1502 events
1503 .iter()
1504 .filter(|event| event.action == LearningAction::LabelCluster
1505 && event.decision_kind == LearningDecisionKind::ClusterQuality
1506 && event.outcome == LearningOutcome::Positive)
1507 .count(),
1508 1
1509 );
1510 assert_eq!(
1511 events
1512 .iter()
1513 .filter(
1514 |event| event.decision_kind == LearningDecisionKind::Membership
1515 && event.outcome == LearningOutcome::Positive
1516 )
1517 .count(),
1518 4
1519 );
1520 assert!(events.iter().all(|event| {
1521 let json = event.features.to_canonical_json().unwrap();
1522 !json.contains("alice") && !json.contains("bob") && !json.contains("/p/")
1523 }));
1524
1525 let conn = learning_seed();
1526 let assigned_cluster =
1527 assign_with_learning(&conn, &[3, 4, 5], "alice", &context()).unwrap();
1528 assert_eq!(assigned_cluster.generation, 1);
1529 assert_eq!(assigned_cluster.event_ids.len(), 4);
1530 let events = list_learning_events(&conn, 20, None).unwrap();
1531 assert_eq!(
1532 events
1533 .iter()
1534 .filter(|event| event.action == LearningAction::AssignCluster
1535 && event.decision_kind == LearningDecisionKind::Membership)
1536 .count(),
1537 3
1538 );
1539 assert_eq!(
1540 events
1541 .iter()
1542 .filter(|event| event.action == LearningAction::AssignCluster
1543 && event.decision_kind == LearningDecisionKind::ClusterQuality)
1544 .count(),
1545 1
1546 );
1547 }
1548
1549 #[test]
1550 fn a_large_cluster_has_a_deterministic_per_action_membership_cap() {
1551 let conn = learning_seed();
1552 for id in 10..22 {
1553 let hash = format!("large-{id}");
1554 conn.execute(
1555 "INSERT INTO faces
1556 (id, hash, bbox, embedding, cluster_id, confirmed, is_primary,
1557 det_score, blur)
1558 VALUES (?1, ?2, '0,0,112,112', ?3, 42, 0, 0, 0.95, 900.0)",
1559 rusqlite::params![id, hash, embedding(0x3c00, (id as u16) + 0x2000)],
1560 )
1561 .unwrap();
1562 }
1563 let ids: Vec<_> = (10..22).collect();
1564 let acknowledgement =
1565 new_person_with_learning(&conn, &ids, "Large Family", &context()).unwrap();
1566 assert_eq!(
1567 acknowledgement.event_ids.len(),
1568 1 + MAX_MEMBERSHIP_EVENTS_PER_ACTION
1569 );
1570 assert_eq!(learning_state(&conn).unwrap().generation, 1);
1571
1572 let events = list_learning_events(&conn, 20, None).unwrap();
1573 assert_eq!(
1574 events
1575 .iter()
1576 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1577 .count(),
1578 MAX_MEMBERSHIP_EVENTS_PER_ACTION
1579 );
1580 assert!(events
1581 .iter()
1582 .filter(|event| event.decision_kind == LearningDecisionKind::Membership)
1583 .all(|event| event.support_count as usize <= MAX_SUPPORT_FACES));
1584 }
1585
1586 #[test]
1587 fn learning_corrections_use_pre_action_state_without_pairwise_dissolve_labels() {
1588 let conn = learning_seed();
1589
1590 let removed_cluster = remove_face_with_learning(&conn, 3, &context()).unwrap();
1591 assert_eq!(removed_cluster.generation, 1);
1592 let removed_person = remove_face_with_learning(&conn, 2, &context()).unwrap();
1593 assert_eq!(removed_person.generation, 2);
1594 let dissolved = dissolve_cluster_with_learning(&conn, 9, &context()).unwrap();
1595 assert_eq!(dissolved.generation, 3);
1596
1597 let events = list_learning_events(&conn, 20, None).unwrap();
1598 assert_eq!(events.len(), 3);
1599 assert_eq!(
1600 events
1601 .iter()
1602 .filter(
1603 |event| event.decision_kind == LearningDecisionKind::Membership
1604 && event.outcome == LearningOutcome::Negative
1605 )
1606 .count(),
1607 2
1608 );
1609 let dissolve = events
1610 .iter()
1611 .find(|event| event.action == LearningAction::DissolveCluster)
1612 .unwrap();
1613 assert_eq!(dissolve.decision_kind, LearningDecisionKind::ClusterQuality);
1614 assert_eq!(dissolve.outcome, LearningOutcome::Negative);
1615 assert_eq!(dissolve.faces.len(), 2);
1616 }
1617
1618 #[test]
1619 fn unsupported_last_face_removals_still_apply_without_fabricated_evidence() {
1620 let conn = learning_seed();
1621 remove_face_with_learning(&conn, 1, &context()).unwrap();
1622 let last_person_face = remove_face_with_learning(&conn, 2, &context()).unwrap();
1623 assert!(last_person_face.event_ids.is_empty());
1624 assert_eq!(last_person_face.generation, 1);
1625 let person_state: (Option<String>, i64) = conn
1626 .query_row(
1627 "SELECT person_label, confirmed FROM faces WHERE id = 2",
1628 [],
1629 |row| Ok((row.get(0)?, row.get(1)?)),
1630 )
1631 .unwrap();
1632 assert_eq!(person_state, (None, 0));
1633
1634 remove_face_with_learning(&conn, 3, &context()).unwrap();
1635 remove_face_with_learning(&conn, 4, &context()).unwrap();
1636 let last_cluster_face = remove_face_with_learning(&conn, 5, &context()).unwrap();
1637 assert!(last_cluster_face.event_ids.is_empty());
1638 assert_eq!(last_cluster_face.generation, 3);
1639 let cluster_id: Option<i64> = conn
1640 .query_row("SELECT cluster_id FROM faces WHERE id = 5", [], |row| {
1641 row.get(0)
1642 })
1643 .unwrap();
1644 assert_eq!(cluster_id, None);
1645 }
1646
1647 #[test]
1651 fn following_the_waiting_ask_starts_a_new_run_and_one_face_does_not() {
1652 let conn = learning_seed();
1653 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1654 videre_core::face_learning::mark_training_started(&conn).unwrap();
1655 let ask = videre_core::face_learning::TrainingError::OneSidedFold {
1656 decision_kind: videre_core::face_learning::LearningDecisionKind::Membership,
1657 lacking_negatives: true,
1658 }
1659 .feedback_needed(&videre_core::face_learning::TrainingConfig::default())
1660 .unwrap();
1661 assert_eq!(ask, "name 1 more person from a group of two or more faces");
1662 videre_core::face_learning::mark_training_waiting(&conn, 1, &ask).unwrap();
1663
1664 let single = new_person_with_learning(&conn, &[6], "Çağla", &context()).unwrap();
1665 assert!(single.event_ids.is_empty());
1666 let status = face_learning_status(&conn).unwrap();
1667 assert_eq!(
1668 (status.generation, status.status.as_str()),
1669 (1, "waiting"),
1670 "one face records nothing, so nothing new is trained"
1671 );
1672 assert_eq!(status.feedback_needed.as_deref(), Some(ask.as_str()));
1673
1674 let group = new_person_with_learning(&conn, &[3, 4, 5], "Özgür", &context()).unwrap();
1675 assert!(!group.event_ids.is_empty());
1676 let status = face_learning_status(&conn).unwrap();
1677 assert_eq!(
1678 (status.generation, status.status.as_str()),
1679 (2, "stale"),
1680 "a named group is new evidence, so the worker trains again"
1681 );
1682 assert_eq!(status.feedback_needed, None);
1683 assert_eq!(
1684 status.last_error, None,
1685 "the ask stored for the waiting run never reads as an error"
1686 );
1687 }
1688
1689 #[test]
1693 fn a_stale_state_never_reports_the_waiting_ask_as_an_error() {
1694 let conn = learning_seed();
1695 assign_with_learning(&conn, &[7, 8], "alice", &context()).unwrap();
1696 videre_core::face_learning::mark_training_started(&conn).unwrap();
1697 videre_core::face_learning::mark_training_waiting(
1698 &conn,
1699 1,
1700 "dissolve 2 more wrong clusters",
1701 )
1702 .unwrap();
1703 conn.execute(
1704 "UPDATE face_learning_state SET generation = generation + 1, status = 'stale'",
1705 [],
1706 )
1707 .unwrap();
1708 let status = face_learning_status(&conn).unwrap();
1709 assert_eq!(status.status, "stale");
1710 assert_eq!(status.last_error, None);
1711 assert_eq!(status.feedback_needed, None);
1712 }
1713
1714 fn insert_profile(conn: &Connection, stage: &str, status: &str, gates: &str) {
1715 videre_core::face_learning::ensure_profile_table(conn).unwrap();
1716 conn.execute(
1717 "INSERT INTO face_learning_profiles (
1718 artifact_version, embedding_model_id, feature_schema_version, model_kind,
1719 parameters, training_evidence_json, validation_report_json, stage, status,
1720 promotion_result_json, created_at
1721 ) VALUES (1, 'm', 1, 'logistic', X'00', '{}', '{}', ?1, ?2, ?3, 'now')",
1722 rusqlite::params![stage, status, gates],
1723 )
1724 .unwrap();
1725 }
1726
1727 #[test]
1728 fn the_summary_says_learning_is_not_used_before_any_profile() {
1729 let conn = learning_seed();
1730 let status = face_learning_status(&conn).unwrap();
1731 assert_eq!(status.active_profile, None);
1732 assert_eq!(
1733 status.summary,
1734 "Learning: not used yet; naming people teaches it."
1735 );
1736 }
1737
1738 #[test]
1739 fn the_summary_names_rejected_candidates_and_the_gate_they_missed() {
1740 let conn = learning_seed();
1741 let gates = r#"[{"dataset_key":"cluster_quality-fold-2","gate":"suggestion_precision","observed":0.8333,"required":0.85}]"#;
1742 insert_profile(&conn, "suggestion", "rejected", gates);
1743 insert_profile(&conn, "suggestion", "rejected", gates);
1744 let status = face_learning_status(&conn).unwrap();
1745 assert_eq!(
1746 status.summary,
1747 "Learning: not used yet; 2 trained candidate(s) did not pass the quality checks \
1748 (suggestion precision 0.83, needs 0.85). More confirmed names help."
1749 );
1750 }
1751
1752 #[test]
1753 fn the_summary_names_the_active_profile() {
1754 let conn = learning_seed();
1755 insert_profile(&conn, "suggestion", "active", "[]");
1756 let status = face_learning_status(&conn).unwrap();
1757 let active = status.active_profile.expect("an active profile");
1758 assert_eq!(active.stage, "suggestion");
1759 assert_eq!(
1760 status.summary,
1761 format!(
1762 "Learning: profile {} suggests names; grouping uses the settings above.",
1763 active.profile_id
1764 )
1765 );
1766 }
1767
1768 #[test]
1769 fn new_person_collision_uses_existing_person_support() {
1770 let conn = learning_seed();
1771 let acknowledgement =
1772 new_person_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1773 assert_eq!(acknowledgement.generation, 1);
1774 assert_eq!(acknowledgement.event_ids.len(), 1);
1775 let events = list_learning_events(&conn, 10, None).unwrap();
1776 assert_eq!(events[0].action, LearningAction::AssignFace);
1777 assert_eq!(events[0].target_identity.as_deref(), Some("alice"));
1778 assert_eq!(events[0].support_count, 2);
1779 }
1780
1781 #[test]
1782 fn assigning_to_a_face_less_person_keeps_only_supported_evidence() {
1783 let conn = learning_seed();
1784 conn.execute(
1785 "UPDATE faces
1786 SET person_label = NULL, confirmed = 0
1787 WHERE person_label = 'alice'",
1788 [],
1789 )
1790 .unwrap();
1791
1792 let singleton = assign_with_learning(&conn, &[6], "Alice", &context()).unwrap();
1793 assert!(singleton.event_ids.is_empty());
1794 assert_eq!(singleton.generation, 0);
1795 assert_eq!(singleton.message_key, "face_named_without_comparison");
1796 let assigned: (Option<String>, i64) = conn
1797 .query_row(
1798 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1799 [],
1800 |row| Ok((row.get(0)?, row.get(1)?)),
1801 )
1802 .unwrap();
1803 assert_eq!(assigned, (Some("alice".to_owned()), 1));
1804 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1805
1806 let conn = learning_seed();
1807 conn.execute(
1808 "UPDATE faces
1809 SET person_label = NULL, confirmed = 0
1810 WHERE person_label = 'alice'",
1811 [],
1812 )
1813 .unwrap();
1814 let cluster = new_person_with_learning(&conn, &[3, 4, 5], "Alice", &context()).unwrap();
1815 assert_eq!(cluster.event_ids.len(), 1);
1816 assert_eq!(cluster.generation, 1);
1817 let events = list_learning_events(&conn, 10, None).unwrap();
1818 assert_eq!(events.len(), 1);
1819 assert_eq!(events[0].action, LearningAction::AssignCluster);
1820 assert_eq!(
1821 events[0].decision_kind,
1822 LearningDecisionKind::ClusterQuality
1823 );
1824 }
1825
1826 #[test]
1827 fn event_insert_failure_rolls_back_the_visible_assignment_and_generation() {
1828 let conn = learning_seed();
1829 conn.execute_batch(
1830 "CREATE TRIGGER reject_learning_event
1831 BEFORE INSERT ON face_learning_events
1832 BEGIN SELECT RAISE(ABORT, 'test rejection'); END;",
1833 )
1834 .unwrap();
1835
1836 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1837 let state: (Option<String>, i64) = conn
1838 .query_row(
1839 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1840 [],
1841 |row| Ok((row.get(0)?, row.get(1)?)),
1842 )
1843 .unwrap();
1844 assert_eq!(state, (None, 0));
1845 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1846 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1847 }
1848
1849 #[test]
1850 fn commit_failure_rolls_back_faces_events_and_generation() {
1851 let conn = learning_seed();
1852 conn.execute_batch(
1853 "PRAGMA foreign_keys = ON;
1854 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
1855 CREATE TABLE commit_guard_child (
1856 event_id INTEGER PRIMARY KEY,
1857 parent_id INTEGER NOT NULL,
1858 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
1859 DEFERRABLE INITIALLY DEFERRED
1860 );
1861 CREATE TRIGGER fail_learning_commit
1862 AFTER INSERT ON face_learning_events
1863 BEGIN
1864 INSERT INTO commit_guard_child (event_id, parent_id)
1865 VALUES (NEW.id, 999);
1866 END;",
1867 )
1868 .unwrap();
1869
1870 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1871 let state: (Option<String>, i64) = conn
1872 .query_row(
1873 "SELECT person_label, confirmed FROM faces WHERE id = 6",
1874 [],
1875 |row| Ok((row.get(0)?, row.get(1)?)),
1876 )
1877 .unwrap();
1878 assert_eq!(state, (None, 0));
1879 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1880 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1881 }
1882
1883 #[test]
1884 fn malformed_or_mixed_prestate_rolls_back_without_learning() {
1885 let conn = learning_seed();
1886 conn.execute("UPDATE faces SET embedding = X'0000' WHERE id = 6", [])
1887 .unwrap();
1888 assert!(assign_with_learning(&conn, &[6], "alice", &context()).is_err());
1889 assert!(new_person_with_learning(&conn, &[3, 7], "Bob", &context()).is_err());
1890 assert!(new_person_with_learning(&conn, &[1], "Bob", &context()).is_err());
1891 assert!(assign_with_learning(&conn, &[999], "alice", &context()).is_err());
1892 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1893 assert!(list_learning_events(&conn, 20, None).unwrap().is_empty());
1894 }
1895
1896 #[test]
1897 fn deleting_a_person_invalidates_identity_evidence_without_a_negative_event() {
1898 let conn = learning_seed();
1899 assign_with_learning(&conn, &[6], "alice", &context()).unwrap();
1900 let acknowledgement = delete_person_with_learning(&conn, "alice")
1901 .unwrap()
1902 .unwrap();
1903 assert_eq!(acknowledgement.generation, 2);
1904 assert!(acknowledgement.event_ids.is_empty());
1905
1906 let events = list_learning_events(&conn, 20, None).unwrap();
1907 assert_eq!(events.len(), 1);
1908 assert!(!events[0].eligible);
1909 assert_eq!(
1910 events[0].invalidation_reason,
1911 Some(videre_core::face_learning::InvalidationReason::PersonRemoved)
1912 );
1913 assert!(delete_person_with_learning(&conn, "alice")
1914 .unwrap()
1915 .is_none());
1916 assert_eq!(learning_state(&conn).unwrap().generation, 2);
1917 }
1918
1919 #[test]
1920 fn deleting_a_person_without_learning_evidence_keeps_generation_current() {
1921 let conn = learning_seed();
1922 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1923
1924 let acknowledgement = delete_person_with_learning(&conn, "alice")
1925 .unwrap()
1926 .unwrap();
1927
1928 assert_eq!(acknowledgement.generation, 0);
1929 assert!(acknowledgement.event_ids.is_empty());
1930 assert_eq!(learning_state(&conn).unwrap().generation, 0);
1931 assert!(list_learning_events(&conn, 10, None).unwrap().is_empty());
1932 }
1933 }
1934
1935 #[test]
1936 fn the_list_comes_back_in_the_same_order_every_time() {
1937 let conn = seed();
1944 conn.execute_batch(
1948 "INSERT INTO file_hashes (hash, path) VALUES ('h6','/p/6.jpg'),('h7','/p/7.jpg'),
1951 ('h8','/p/8.jpg'),('h9','/p/9.jpg'),('h10','/p/10.jpg');
1952 INSERT INTO people (name, full_name) VALUES ('bob','Bob');
1953 INSERT INTO faces (id,hash,bbox,embedding,cluster_id,person_label,confirmed,is_primary) VALUES
1954 (6,'h6','0,0,9,9',X'0000',9,NULL,0,0),
1955 (7,'h7','0,0,9,9',X'0000',9,NULL,0,0),
1956 (8,'h8','0,0,9,9',X'0000',9,NULL,0,0),
1957 (9,'h9','0,0,9,9',X'0000',3,NULL,0,0),
1958 (10,'h10','0,0,9,9',X'0000',NULL,'bob',1,0);",
1959 )
1960 .unwrap();
1961
1962 let a = faces_list(&conn).unwrap();
1965 let b = faces_list(&conn).unwrap();
1966
1967 let ids = |f: &FacesData| -> Vec<i64> { f.clusters.iter().map(|c| c.cluster_id).collect() };
1968 let names =
1969 |f: &FacesData| -> Vec<String> { f.people.iter().map(|p| p.label.clone()).collect() };
1970 assert!(ids(&a).len() >= 3, "fixture must have several clusters");
1971 assert_eq!(
1972 ids(&a),
1973 ids(&b),
1974 "cluster order must not change between calls"
1975 );
1976 assert_eq!(
1977 names(&a),
1978 names(&b),
1979 "people order must not change between calls"
1980 );
1981
1982 let sizes: Vec<usize> = a.clusters.iter().map(|c| c.face_ids.len()).collect();
1985 let mut want = sizes.clone();
1986 want.sort_unstable_by(|x, y| y.cmp(x));
1987 assert_eq!(
1988 sizes, want,
1989 "clusters must be ordered largest first, got {sizes:?}"
1990 );
1991 }
1992
1993 #[test]
1994 fn faces_list_splits_people_clusters_singletons() {
1995 let conn = seed();
1996 let d = faces_list(&conn).unwrap();
1997 assert_eq!(d.people.len(), 1);
1998 assert_eq!(d.people[0].label, "alice");
2000 assert_eq!(d.people[0].full_name, "Alice");
2001 assert_eq!(
2002 d.people[0].representative_id, 1,
2003 "primary face is representative"
2004 );
2005 assert_eq!(d.clusters.len(), 1);
2006 assert_eq!(d.clusters[0].cluster_id, 7);
2007 assert_eq!(d.clusters[0].face_ids, vec![3, 4]);
2008 assert_eq!(d.singletons.len(), 1);
2009 assert_eq!(d.singletons[0].face_id, 5);
2010 }
2011
2012 #[test]
2013 fn person_detail_marks_primary() {
2014 let conn = seed();
2015 let p = person_detail(&conn, "Alice").unwrap();
2016 assert_eq!(p.faces.len(), 2);
2017 assert!(p.faces[0].is_primary, "primary sorts first and is flagged");
2018 assert!(!p.faces[1].is_primary);
2019 }
2020
2021 #[test]
2022 fn cluster_detail_lists_faces() {
2023 let conn = seed();
2024 let c = cluster_detail(&conn, 7).unwrap();
2025 assert_eq!(c.cluster_id, 7);
2026 assert_eq!(
2027 c.faces.iter().map(|f| f.face_id).collect::<Vec<_>>(),
2028 vec![3, 4]
2029 );
2030 }
2031
2032 #[test]
2033 fn assign_labels_and_confirms() {
2034 let conn = seed();
2035 assign(&conn, &[3, 4], "Bob").unwrap();
2036 let p = person_detail(&conn, "Bob").unwrap();
2037 assert_eq!(p.faces.len(), 2, "both faces now confirmed under Bob");
2038 }
2039
2040 #[test]
2041 fn assign_rejects_empty_label() {
2042 let conn = seed();
2043 assert!(matches!(assign(&conn, &[3], " "), Err(Error::Invalid)));
2044 }
2045
2046 #[test]
2047 fn remove_face_unassigns_everything() {
2048 let conn = seed();
2049 remove_face(&conn, 1).unwrap();
2050 let (cid, label, confirmed, prim): (Option<i64>, Option<String>, i64, i64) = conn
2051 .query_row(
2052 "SELECT cluster_id, person_label, confirmed, is_primary FROM faces WHERE id=1",
2053 [],
2054 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
2055 )
2056 .unwrap();
2057 assert_eq!((cid, label, confirmed, prim), (None, None, 0, 0));
2058 }
2059
2060 #[test]
2061 fn dissolve_cluster_nulls_cluster_id() {
2062 let conn = seed();
2063 dissolve_cluster(&conn, 7).unwrap();
2064 assert_eq!(faces_list(&conn).unwrap().clusters.len(), 0);
2065 assert_eq!(
2066 faces_list(&conn).unwrap().singletons.len(),
2067 3,
2068 "3,4 join 5 as singletons"
2069 );
2070 }
2071
2072 #[test]
2073 fn deleting_a_missing_person_leaves_the_regrouping_gate_alone() {
2074 let conn = seed();
2077 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
2078 let before = videre_core::face_db::recluster_watermark(&conn).unwrap();
2079 assert!(before > 0);
2080 delete_person(&conn, "ghost").unwrap();
2081 assert_eq!(
2082 videre_core::face_db::recluster_watermark(&conn).unwrap(),
2083 before,
2084 "a no-op delete must not reopen the gated regroup"
2085 );
2086 }
2087
2088 #[test]
2089 fn delete_person_returns_faces_to_the_unassigned_pool_and_reopens_regrouping() {
2090 let conn = seed();
2097 assign(&conn, &[1, 2], "Alice").unwrap();
2098 assert_eq!(faces_list(&conn).unwrap().people.len(), 1);
2099 videre_core::face_db::advance_recluster_watermark(&conn).unwrap();
2102 assert!(videre_core::face_db::recluster_watermark(&conn).unwrap() > 0);
2103
2104 delete_person(&conn, "Alice").unwrap();
2105 assert_eq!(faces_list(&conn).unwrap().people.len(), 0, "Alice is gone");
2106 assert_eq!(
2107 videre_core::face_db::recluster_watermark(&conn).unwrap(),
2108 0,
2109 "deleting a person must reopen the gated regroup for their faces"
2110 );
2111 let rows: Vec<(Option<i64>, Option<String>, i64)> = {
2112 let mut s = conn
2113 .prepare("SELECT cluster_id, person_label, confirmed FROM faces WHERE id IN (1, 2) ORDER BY id")
2114 .unwrap();
2115 s.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
2116 .unwrap()
2117 .collect::<rusqlite::Result<_>>()
2118 .unwrap()
2119 };
2120 assert!(
2121 rows.iter()
2122 .all(|(cid, label, confirmed)| cid.is_none() && label.is_none() && *confirmed == 0),
2123 "every face returns to the unassigned pool: {rows:?}"
2124 );
2125 }
2126
2127 #[test]
2128 fn set_primary_is_exclusive_per_person() {
2129 let conn = seed();
2130 set_primary(&conn, 2, "Alice").unwrap();
2131 let primaries: Vec<i64> = {
2132 let mut s = conn
2133 .prepare("SELECT id FROM faces WHERE person_label='alice' AND is_primary=1")
2134 .unwrap();
2135 s.query_map([], |r| r.get(0))
2136 .unwrap()
2137 .collect::<rusqlite::Result<_>>()
2138 .unwrap()
2139 };
2140 assert_eq!(primaries, vec![2], "exactly one primary, now face 2");
2141 }
2142
2143 #[test]
2144 fn renaming_only_the_spelling_keeps_the_identity() {
2145 let conn = seed();
2148 set_full_name(&conn, "alice", "Alice Smith").unwrap();
2149 let (name, full): (String, String) = conn
2150 .query_row("SELECT name, full_name FROM people", [], |r| {
2151 Ok((r.get(0)?, r.get(1)?))
2152 })
2153 .unwrap();
2154 assert_eq!(name, "alice", "identity is unchanged");
2155 assert_eq!(full, "Alice Smith", "only the display name moved");
2156 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 2);
2157 }
2158
2159 #[test]
2166 fn assign_a_missing_face_is_not_found() {
2167 let conn = seed();
2168 assert!(matches!(assign(&conn, &[999], "Bob"), Err(Error::NotFound)));
2169 }
2170
2171 #[test]
2172 fn assign_is_atomic_when_one_face_is_missing() {
2173 let conn = seed();
2177 assert!(matches!(
2178 assign(&conn, &[3, 999], "Bob"),
2179 Err(Error::NotFound)
2180 ));
2181 let (label, confirmed): (Option<String>, i64) = conn
2182 .query_row(
2183 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2184 [],
2185 |r| Ok((r.get(0)?, r.get(1)?)),
2186 )
2187 .unwrap();
2188 assert_eq!(label, None, "face 3 must not have been labelled");
2189 assert_eq!(confirmed, 0, "face 3 must not have been confirmed");
2190 let bob: i64 = conn
2191 .query_row("SELECT COUNT(*) FROM people WHERE name = 'bob'", [], |r| {
2192 r.get(0)
2193 })
2194 .unwrap();
2195 assert_eq!(
2196 bob, 0,
2197 "no person may be created when the assign rolls back"
2198 );
2199 }
2200
2201 #[test]
2202 fn assign_commit_failure_rolls_back_and_closes_the_transaction() {
2203 let conn = seed();
2204 conn.execute_batch(
2205 "PRAGMA foreign_keys = ON;
2206 CREATE TABLE commit_guard_parent (id INTEGER PRIMARY KEY);
2207 CREATE TABLE commit_guard_child (
2208 face_id INTEGER PRIMARY KEY,
2209 parent_id INTEGER NOT NULL,
2210 FOREIGN KEY(parent_id) REFERENCES commit_guard_parent(id)
2211 DEFERRABLE INITIALLY DEFERRED
2212 );
2213 CREATE TRIGGER fail_assign_commit
2214 AFTER UPDATE ON faces
2215 WHEN NEW.id = 3
2216 BEGIN
2217 INSERT INTO commit_guard_child (face_id, parent_id)
2218 VALUES (NEW.id, 999);
2219 END;",
2220 )
2221 .unwrap();
2222
2223 assert!(assign(&conn, &[3], "Bob").is_err());
2224 assert!(conn.is_autocommit());
2225 let state: (Option<String>, i64) = conn
2226 .query_row(
2227 "SELECT person_label, confirmed FROM faces WHERE id = 3",
2228 [],
2229 |row| Ok((row.get(0)?, row.get(1)?)),
2230 )
2231 .unwrap();
2232 assert_eq!(state, (None, 0));
2233 let bob: i64 = conn
2234 .query_row(
2235 "SELECT COUNT(*) FROM people WHERE name = 'bob'",
2236 [],
2237 |row| row.get(0),
2238 )
2239 .unwrap();
2240 assert_eq!(bob, 0);
2241 }
2242
2243 #[test]
2244 fn assign_rejects_empty_face_ids() {
2245 let conn = seed();
2248 assert!(matches!(assign(&conn, &[], "Bob"), Err(Error::Invalid)));
2249 }
2250
2251 #[test]
2252 fn remove_face_missing_is_not_found() {
2253 let conn = seed();
2254 assert!(matches!(remove_face(&conn, 999), Err(Error::NotFound)));
2255 }
2256
2257 #[test]
2258 fn dissolve_cluster_missing_is_not_found() {
2259 let conn = seed();
2260 assert!(matches!(dissolve_cluster(&conn, 999), Err(Error::NotFound)));
2261 }
2262
2263 #[test]
2264 fn set_primary_missing_face_is_not_found() {
2265 let conn = seed();
2266 assert!(matches!(
2267 set_primary(&conn, 999, "Alice"),
2268 Err(Error::NotFound)
2269 ));
2270 }
2271
2272 #[test]
2273 fn set_primary_face_of_another_person_is_not_found_and_rolls_back() {
2274 let conn = seed();
2278 assert!(matches!(
2279 set_primary(&conn, 5, "Alice"),
2280 Err(Error::NotFound)
2281 ));
2282 let primary: i64 = conn
2283 .query_row(
2284 "SELECT id FROM faces WHERE person_label = 'alice' AND is_primary = 1",
2285 [],
2286 |r| r.get(0),
2287 )
2288 .unwrap();
2289 assert_eq!(
2290 primary, 1,
2291 "the original primary must be restored on rollback"
2292 );
2293 }
2294
2295 #[test]
2296 fn delete_person_missing_is_idempotent_success() {
2297 let conn = seed();
2303 assert!(delete_person(&conn, "Nobody").is_ok());
2304 }
2305}
2306
2307#[cfg(test)]
2308mod identity_tests {
2309 use super::tests::seed;
2310 use super::*;
2311
2312 fn people(conn: &Connection) -> Vec<(String, String)> {
2313 conn.prepare("SELECT name, full_name FROM people ORDER BY name")
2314 .unwrap()
2315 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
2316 .unwrap()
2317 .collect::<rusqlite::Result<_>>()
2318 .unwrap()
2319 }
2320
2321 #[test]
2322 fn assign_stores_the_identity_and_records_the_display_name() {
2323 let conn = seed();
2324 assign(&conn, &[3], "Işıl Özyeğin").unwrap();
2325
2326 let label: String = conn
2327 .query_row("SELECT person_label FROM faces WHERE id = 3", [], |r| {
2328 r.get(0)
2329 })
2330 .unwrap();
2331 assert_eq!(label, "isil_ozyegin", "faces hold the identity");
2332 assert!(
2333 people(&conn).contains(&("isil_ozyegin".into(), "Işıl Özyeğin".into())),
2334 "and the spelling is kept for display"
2335 );
2336 }
2337
2338 #[test]
2339 fn assigning_an_existing_name_in_another_case_joins_that_person() {
2340 let conn = seed();
2343 assign(&conn, &[3], "ALICE").unwrap();
2344 assert_eq!(people(&conn).len(), 1, "still one person, not two");
2345 assert_eq!(person_detail(&conn, "alice").unwrap().faces.len(), 3);
2346 assert_eq!(
2347 people(&conn)[0].1,
2348 "Alice",
2349 "the existing spelling is not overwritten by the new casing"
2350 );
2351 }
2352
2353 #[test]
2354 fn assign_rejects_a_name_with_no_usable_identity() {
2355 let conn = seed();
2358 assert!(matches!(assign(&conn, &[3], "!!!"), Err(Error::Invalid)));
2359 }
2360
2361 #[test]
2362 fn person_detail_resolves_every_form_of_the_name() {
2363 let conn = seed();
2364 for form in ["alice", "Alice", "ALICE", " alice "] {
2365 assert_eq!(
2366 person_detail(&conn, form).unwrap().faces.len(),
2367 2,
2368 "form {form:?}"
2369 );
2370 }
2371 }
2372
2373 #[test]
2374 fn person_detail_reports_the_display_name() {
2375 let d = person_detail(&seed(), "alice").unwrap();
2376 assert_eq!(d.label, "alice");
2377 assert_eq!(d.full_name, "Alice");
2378 }
2379
2380 #[test]
2381 fn person_detail_falls_back_when_there_is_no_people_row() {
2382 let conn = seed();
2386 conn.execute_batch("PRAGMA foreign_keys = OFF").unwrap();
2387 conn.execute(
2388 "INSERT INTO faces (id,hash,bbox,embedding,person_label,confirmed) \
2389 VALUES (9,'h9','0,0,9,9',X'0000','orphan',1)",
2390 [],
2391 )
2392 .unwrap();
2393 conn.execute_batch("PRAGMA foreign_keys = ON").unwrap();
2394 let d = person_detail(&conn, "orphan").unwrap();
2395 assert_eq!(d.full_name, "orphan", "falls back to the identity");
2396 }
2397
2398 #[test]
2399 fn set_full_name_changes_only_the_display_name() {
2400 let conn = seed();
2401 set_full_name(&conn, "alice", "Alice Smith").unwrap();
2402 assert_eq!(people(&conn), vec![("alice".into(), "Alice Smith".into())]);
2403 assert_eq!(
2404 person_detail(&conn, "alice").unwrap().faces.len(),
2405 2,
2406 "no face was touched"
2407 );
2408 }
2409
2410 #[test]
2411 fn set_full_name_accepts_any_form_of_the_identity() {
2412 let conn = seed();
2413 set_full_name(&conn, "ALICE", "Alice Smith").unwrap();
2414 assert_eq!(people(&conn)[0].1, "Alice Smith");
2415 }
2416
2417 #[test]
2418 fn set_full_name_on_a_missing_person_is_not_found() {
2419 assert!(matches!(
2420 set_full_name(&seed(), "nobody", "Someone"),
2421 Err(Error::NotFound)
2422 ));
2423 }
2424
2425 #[test]
2426 fn set_full_name_rejects_an_empty_display_name() {
2427 assert!(matches!(
2429 set_full_name(&seed(), "alice", " "),
2430 Err(Error::Invalid)
2431 ));
2432 }
2433
2434 #[test]
2435 fn delete_person_accepts_any_form_of_the_name() {
2436 let conn = seed();
2437 delete_person(&conn, "Alice").unwrap();
2438 let left: i64 = conn
2439 .query_row(
2440 "SELECT COUNT(*) FROM faces WHERE person_label IS NOT NULL",
2441 [],
2442 |r| r.get(0),
2443 )
2444 .unwrap();
2445 assert_eq!(left, 0, "faces are unassigned whichever form was passed");
2446 }
2447
2448 #[test]
2449 fn set_primary_accepts_any_form_of_the_name() {
2450 let conn = seed();
2451 set_primary(&conn, 2, "ALICE").unwrap();
2452 let primary: i64 = conn
2453 .query_row(
2454 "SELECT id FROM faces WHERE person_label='alice' AND is_primary=1",
2455 [],
2456 |r| r.get(0),
2457 )
2458 .unwrap();
2459 assert_eq!(primary, 2);
2460 }
2461}
2462
2463#[cfg(test)]
2464mod never_run_tests {
2465 use super::*;
2466
2467 #[test]
2479 fn a_library_that_never_ran_detection_is_empty_not_an_error() {
2480 let conn = Connection::open_in_memory().unwrap();
2481 conn.execute_batch(
2482 "CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);
2483 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT);",
2484 )
2485 .unwrap();
2486
2487 let data = faces_list(&conn).expect("a library with no faces table is not an error");
2488 assert!(data.people.is_empty());
2489 assert!(data.clusters.is_empty());
2490 assert!(data.singletons.is_empty());
2491 }
2492
2493 mod question_fixture {
2496 use super::*;
2497 use videre_core::face_learning::{
2498 ensure_question_tables, replace_pending_questions, select_questions, LogisticModel,
2499 LogisticScorer, ModelBundle, QuestionSelectionConfig, MEMBERSHIP_FEATURE_NAMES,
2500 MODEL_ARTIFACT_VERSION,
2501 };
2502
2503 pub fn embedding_blob(x: f32, y: f32) -> Vec<u8> {
2504 let mut bytes = Vec::with_capacity(4);
2505 bytes.extend_from_slice(&half::f16::from_f32(x).to_le_bytes());
2506 bytes.extend_from_slice(&half::f16::from_f32(y).to_le_bytes());
2507 bytes
2508 }
2509
2510 fn logistic_bundle() -> ModelBundle {
2511 let names: Vec<String> = MEMBERSHIP_FEATURE_NAMES
2512 .iter()
2513 .map(|name| name.to_string())
2514 .collect();
2515 let means: Vec<f64> = names
2516 .iter()
2517 .map(|name| if name == "similarity_mean" { 1.0 } else { 0.0 })
2518 .collect();
2519 let scales: Vec<f64> = names
2520 .iter()
2521 .map(|name| if name == "similarity_mean" { 0.5 } else { 1.0 })
2522 .collect();
2523 let weights: Vec<f64> = names
2524 .iter()
2525 .map(|name| if name == "similarity_mean" { 2.0 } else { 0.0 })
2526 .collect();
2527 let scorer = LogisticScorer {
2528 model: LogisticModel {
2529 feature_names: names,
2530 means,
2531 scales,
2532 intercept: 0.0,
2533 weights,
2534 l2: 1.0,
2535 positive_class_weight: 1.0,
2536 },
2537 calibration: videre_core::face_learning::CalibrationModel {
2538 intercept: 0.0,
2539 slope: 1.0,
2540 },
2541 threshold: 0.5,
2542 };
2543 ModelBundle::Logistic {
2544 artifact_version: MODEL_ARTIFACT_VERSION,
2545 embedding_model_id: "arcface/test".into(),
2546 feature_schema_version: 1,
2547 membership: scorer.clone(),
2548 cluster_quality: scorer,
2549 }
2550 }
2551
2552 pub fn library() -> (Connection, i64, i64) {
2558 let conn = Connection::open_in_memory().unwrap();
2559 conn.execute_batch(
2560 "PRAGMA foreign_keys = ON;
2561 CREATE TABLE people (name TEXT PRIMARY KEY, full_name TEXT NOT NULL);
2562 CREATE TABLE faces (id INTEGER PRIMARY KEY, hash TEXT NOT NULL,
2563 bbox TEXT NOT NULL, landmark TEXT, embedding BLOB NOT NULL,
2564 cluster_id INTEGER,
2565 person_label TEXT REFERENCES people(name) ON DELETE RESTRICT ON UPDATE RESTRICT,
2566 confirmed INTEGER DEFAULT 0,
2567 is_primary INTEGER DEFAULT 0, det_score REAL, blur REAL, oriented INTEGER);",
2568 )
2569 .unwrap();
2570 videre_core::face_learning::ensure_learning_tables(&conn).unwrap();
2571 videre_core::face_learning::ensure_profile_table(&conn).unwrap();
2572 ensure_question_tables(&conn).unwrap();
2573
2574 for (id, cluster) in [(10, Some(1)), (11, Some(1)), (12, None), (13, None)] {
2575 conn.execute(
2576 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2577 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, 0.9, 600.0)",
2578 rusqlite::params![id, embedding_blob(1.0, 0.0), cluster],
2579 )
2580 .unwrap();
2581 }
2582 assign(&conn, &[12, 13], "Alice").unwrap();
2583
2584 let evidence =
2585 serde_json::to_string(&videre_core::face_learning::TrainingEvidenceCounts {
2586 positive_pairs: 20,
2587 negative_pairs: 20,
2588 explicit_negative_pairs: 0,
2589 })
2590 .unwrap();
2591 let report = serde_json::to_string(&videre_core::face_learning::ValidationReport {
2592 protocol_version: 1,
2593 evidence_schema_version: 1,
2594 feature_schema_version: 1,
2595 datasets: Vec::new(),
2596 })
2597 .unwrap();
2598 conn.execute(
2599 "INSERT INTO face_learning_profiles (
2600 artifact_version, embedding_model_id, feature_schema_version, model_kind,
2601 parameters, training_evidence_json, validation_report_json, stage, status
2602 ) VALUES (1, 'arcface/test', 1, 'logistic', ?1, ?2, ?3, 'suggestion', 'active')",
2603 rusqlite::params![
2604 serde_json::to_vec(&logistic_bundle()).unwrap(),
2605 evidence,
2606 report
2607 ],
2608 )
2609 .unwrap();
2610 let profile_id = conn.last_insert_rowid();
2611
2612 let candidates = select_questions(&conn, &QuestionSelectionConfig::default()).unwrap();
2613 assert_eq!(candidates.len(), 1, "fixture must produce one question");
2614 let stored = replace_pending_questions(&conn, &candidates).unwrap();
2615 assert_eq!(stored.len(), 1);
2616 (conn, stored[0].id, profile_id)
2617 }
2618
2619 pub fn stub_evidence() -> videre_core::face_learning::DecisionEvidence {
2620 use videre_core::face_learning::{
2621 Calibration, DecisionKind, DecisionOutcome, DecisionTarget, FeatureContribution,
2622 ValidationSummary, EVIDENCE_SCHEMA_VERSION, FEATURE_SCHEMA_VERSION,
2623 };
2624 let evidence = videre_core::face_learning::DecisionEvidence {
2625 schema_version: EVIDENCE_SCHEMA_VERSION,
2626 profile_id: 1,
2627 feature_schema_version: FEATURE_SCHEMA_VERSION,
2628 decision_kind: DecisionKind::Membership,
2629 outcome: DecisionOutcome::Allowed,
2630 subject_face_ids: vec![10],
2631 target: DecisionTarget::Person("alice".into()),
2632 intercept: 0.0,
2633 raw_logit: 0.0,
2634 calibration: Calibration {
2635 intercept: 0.0,
2636 slope: 1.0,
2637 },
2638 calibrated_confidence: 0.5,
2639 threshold: 0.5,
2640 margin: 0.0,
2641 features: vec![FeatureContribution {
2642 name: "similarity_mean".into(),
2643 value: 1.0,
2644 contribution: 0.0,
2645 }],
2646 support_face_ids: vec![12, 13],
2647 rule_vetoes: Vec::new(),
2648 validation: ValidationSummary {
2649 protocol_version: 1,
2650 datasets: 1,
2651 pair_precision: None,
2652 pair_recall: None,
2653 suggestion_precision: None,
2654 suggestion_coverage: None,
2655 },
2656 };
2657 evidence.validate().unwrap();
2658 evidence
2659 }
2660
2661 pub fn context(profile_id: i64) -> TeachingContext {
2662 TeachingContext {
2663 embedding_model_id: "arcface/test".into(),
2664 active_profile_id: Some(profile_id),
2665 }
2666 }
2667 }
2668
2669 use question_fixture as qf;
2670
2671 #[test]
2672 fn deleting_a_person_supersedes_questions_and_advances_once() {
2673 let (conn, _question_id, _profile_id) = qf::library();
2674 let second = videre_core::face_learning::StoredQuestion {
2676 id: 999,
2677 status: videre_core::face_learning::QuestionStatus::Pending,
2678 subject_face_ids: vec![10],
2679 support_face_ids: vec![12, 13],
2680 target_identity: "alice".into(),
2681 target_display: "Alice".into(),
2682 profile_id: 1,
2683 model_kind: "logistic".into(),
2684 representative_face_id: 10,
2685 cluster_id: 1,
2686 evidence_revision: "another-revision".into(),
2687 evidence: qf::stub_evidence(),
2688 created_at: "2026-01-01 00:00:00".into(),
2689 decided_at: None,
2690 };
2691 let _ = second;
2692 delete_person_with_learning(&conn, "Alice").unwrap();
2693 let superseded: i64 = conn
2694 .query_row(
2695 "SELECT count(*) FROM face_learning_questions WHERE status = 'superseded'",
2696 [],
2697 |row| row.get(0),
2698 )
2699 .unwrap();
2700 assert_eq!(superseded, 1, "the pending question must be superseded");
2701 let state = learning_state(&conn).unwrap();
2702 assert_eq!(state.generation, 1, "exactly one generation advance");
2703 let invalidated: i64 = conn
2704 .query_row(
2705 "SELECT count(*) FROM face_learning_events WHERE eligible = 0",
2706 [],
2707 |row| row.get(0),
2708 )
2709 .unwrap();
2710 assert_eq!(invalidated, 0, "no events existed to invalidate");
2711 }
2712
2713 #[test]
2714 fn the_journal_reports_availability_without_rewriting_history() {
2715 let (conn, _question_id, profile_id) = qf::library();
2716 assign_with_learning(&conn, &[10, 11], "Alice", &qf::context(profile_id)).unwrap();
2718 let subject_event_id = face_learning_events(&conn, 50, None, Some("arcface/test"))
2719 .unwrap()
2720 .iter()
2721 .find(|proof| proof.event.faces.iter().any(|face| face.face_id == 10))
2722 .map(|proof| proof.event.id)
2723 .unwrap();
2724 conn.execute("DELETE FROM faces WHERE id = 10", []).unwrap();
2727
2728 let proofs = face_learning_events(&conn, 50, None, Some("arcface/test")).unwrap();
2729 let proof = proofs
2730 .iter()
2731 .find(|proof| proof.event.id == subject_event_id)
2732 .unwrap();
2733 assert!(!proof.source_available, "the subject face is gone");
2734 assert!(!proof.incompatible, "same model and schema stay usable");
2735 assert!(proof.event.eligible, "missing provenance stays eligible");
2736
2737 let proofs = face_learning_events(&conn, 50, None, Some("other/model")).unwrap();
2739 let proof = proofs
2740 .iter()
2741 .find(|proof| proof.event.id == subject_event_id)
2742 .unwrap();
2743 assert!(proof.incompatible);
2744
2745 let (conn, question_id, profile_id) = qf::library();
2747 answer_question_with_learning(
2748 &conn,
2749 question_id,
2750 videre_core::face_learning::QuestionAnswer::No,
2751 &qf::context(profile_id),
2752 )
2753 .unwrap();
2754 let before: String = conn
2755 .query_row(
2756 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2757 [],
2758 |row| row.get(0),
2759 )
2760 .unwrap();
2761 delete_person_with_learning(&conn, "Alice").unwrap();
2762 let after: String = conn
2763 .query_row(
2764 "SELECT feature_snapshot_json FROM face_learning_events WHERE id = 1",
2765 [],
2766 |row| row.get(0),
2767 )
2768 .unwrap();
2769 assert_eq!(before, after, "historical feature JSON never mutates");
2770 }
2771
2772 #[test]
2773 fn yes_confirms_the_target_and_teaches_positive_membership() {
2774 let (conn, question_id, profile_id) = qf::library();
2775 let outcome = answer_question_with_learning(
2776 &conn,
2777 question_id,
2778 QuestionAnswer::Yes,
2779 &qf::context(profile_id),
2780 )
2781 .unwrap();
2782 assert_eq!(outcome.status, "answered");
2783 let ack = outcome.acknowledgement.expect("yes must teach");
2784 assert_eq!(ack.event_ids.len(), 1);
2785 assert_eq!(ack.generation, 1);
2786
2787 let labeled: i64 = conn
2788 .query_row(
2789 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND person_label = 'alice'
2790 AND confirmed = 1 AND cluster_id IS NULL",
2791 [],
2792 |row| row.get(0),
2793 )
2794 .unwrap();
2795 assert_eq!(labeled, 2, "yes labels the whole subject cluster");
2796
2797 let event: (String, String, String) = conn
2798 .query_row(
2799 "SELECT action_kind, outcome, target_identity FROM face_learning_events",
2800 [],
2801 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2802 )
2803 .unwrap();
2804 assert_eq!(event.0, "question_yes");
2805 assert_eq!(event.1, "positive");
2806 assert_eq!(event.2, "alice");
2807 }
2808
2809 #[test]
2810 fn no_teaches_negative_without_labeling() {
2811 let (conn, question_id, profile_id) = qf::library();
2812 let outcome = answer_question_with_learning(
2813 &conn,
2814 question_id,
2815 QuestionAnswer::No,
2816 &qf::context(profile_id),
2817 )
2818 .unwrap();
2819 assert_eq!(outcome.status, "answered");
2820
2821 let untouched: i64 = conn
2822 .query_row(
2823 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 0
2824 AND person_label IS NULL AND cluster_id = 1",
2825 [],
2826 |row| row.get(0),
2827 )
2828 .unwrap();
2829 assert_eq!(untouched, 2, "no must not label");
2830
2831 let event: (String, String) = conn
2832 .query_row(
2833 "SELECT action_kind, outcome FROM face_learning_events",
2834 [],
2835 |row| Ok((row.get(0)?, row.get(1)?)),
2836 )
2837 .unwrap();
2838 assert_eq!(event.0, "question_no");
2839 assert_eq!(event.1, "negative");
2840 }
2841
2842 #[test]
2843 fn skip_only_changes_delivery_state() {
2844 let (conn, question_id, profile_id) = qf::library();
2845 let outcome = answer_question_with_learning(
2846 &conn,
2847 question_id,
2848 QuestionAnswer::Skip,
2849 &qf::context(profile_id),
2850 )
2851 .unwrap();
2852 assert_eq!(outcome.status, "skipped");
2853 assert!(outcome.acknowledgement.is_none());
2854
2855 let events: i64 = conn
2856 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2857 row.get(0)
2858 })
2859 .unwrap();
2860 assert_eq!(events, 0, "skip produces no event");
2861 let state = learning_state(&conn).unwrap();
2862 assert_eq!(state.generation, 0, "skip does not advance generation");
2863 }
2864
2865 #[test]
2866 fn stale_answers_conflict_without_partial_writes() {
2867 let (conn, question_id, profile_id) = qf::library();
2869 assign(&conn, &[10, 11], "Bob").unwrap();
2870 assert!(matches!(
2871 answer_question_with_learning(
2872 &conn,
2873 question_id,
2874 QuestionAnswer::Yes,
2875 &qf::context(profile_id)
2876 ),
2877 Err(Error::Conflict)
2878 ));
2879 let events: i64 = conn
2880 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2881 row.get(0)
2882 })
2883 .unwrap();
2884 assert_eq!(events, 0, "a conflict must not teach");
2885 assert_eq!(
2886 videre_core::face_learning::stored_question(&conn, question_id)
2887 .unwrap()
2888 .unwrap()
2889 .status,
2890 QuestionStatus::Superseded
2891 );
2892
2893 let (conn, question_id, profile_id) = qf::library();
2896 conn.execute_batch(
2897 "UPDATE faces SET person_label = NULL, confirmed = 0 WHERE person_label = 'alice';
2898 DELETE FROM people WHERE name = 'alice';",
2899 )
2900 .unwrap();
2901 assert!(matches!(
2902 answer_question_with_learning(
2903 &conn,
2904 question_id,
2905 QuestionAnswer::No,
2906 &qf::context(profile_id)
2907 ),
2908 Err(Error::Conflict)
2909 ));
2910 assert_eq!(
2911 videre_core::face_learning::stored_question(&conn, question_id)
2912 .unwrap()
2913 .unwrap()
2914 .status,
2915 QuestionStatus::Superseded
2916 );
2917
2918 let (conn, question_id, profile_id) = qf::library();
2920 conn.execute("UPDATE face_learning_profiles SET status = 'retired'", [])
2921 .unwrap();
2922 let _ = profile_id;
2923 assert!(matches!(
2924 answer_question_with_learning(&conn, question_id, QuestionAnswer::No, &qf::context(99)),
2925 Err(Error::Conflict)
2926 ));
2927 assert_eq!(
2928 videre_core::face_learning::stored_question(&conn, question_id)
2929 .unwrap()
2930 .unwrap()
2931 .status,
2932 QuestionStatus::Superseded
2933 );
2934
2935 let (conn, question_id, profile_id) = qf::library();
2937 assign(&conn, &[13], "Alice").unwrap();
2938 remove_face(&conn, 12).unwrap();
2939 insert_face_with_score(&conn, 14, None, 0.9);
2940 assign(&conn, &[14], "Alice").unwrap();
2941 assert!(matches!(
2942 answer_question_with_learning(
2943 &conn,
2944 question_id,
2945 QuestionAnswer::No,
2946 &qf::context(profile_id)
2947 ),
2948 Err(Error::Conflict)
2949 ));
2950 let question = videre_core::face_learning::stored_question(&conn, question_id)
2951 .unwrap()
2952 .unwrap();
2953 assert_eq!(
2954 question.status,
2955 videre_core::face_learning::QuestionStatus::Superseded
2956 );
2957 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
2958 }
2959
2960 fn insert_face_with_score(conn: &Connection, id: i64, cluster: Option<i64>, score: f64) {
2961 conn.execute(
2962 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur)
2963 VALUES (?1, 'h' || ?1, '0,0,80,80', ?2, ?3, 0, ?4, 600.0)",
2964 rusqlite::params![id, qf::embedding_blob(1.0, 0.0), cluster, score],
2965 )
2966 .unwrap();
2967 }
2968
2969 #[test]
2970 fn faces_moved_out_of_the_question_cluster_conflict() {
2971 let (conn, question_id, profile_id) = qf::library();
2972 conn.execute("UPDATE faces SET cluster_id = 9 WHERE id = 11", [])
2975 .unwrap();
2976 assert!(matches!(
2977 answer_question_with_learning(
2978 &conn,
2979 question_id,
2980 QuestionAnswer::Yes,
2981 &qf::context(profile_id)
2982 ),
2983 Err(Error::Conflict)
2984 ));
2985
2986 let labeled: i64 = conn
2987 .query_row(
2988 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
2989 [],
2990 |row| row.get(0),
2991 )
2992 .unwrap();
2993 assert_eq!(labeled, 0, "a stale cluster must not label");
2994 let events: i64 = conn
2995 .query_row("SELECT count(*) FROM face_learning_events", [], |row| {
2996 row.get(0)
2997 })
2998 .unwrap();
2999 assert_eq!(events, 0);
3000 let question = videre_core::face_learning::stored_question(&conn, question_id)
3001 .unwrap()
3002 .unwrap();
3003 assert_eq!(
3004 question.status,
3005 videre_core::face_learning::QuestionStatus::Superseded
3006 );
3007 assert!(pending_identity_questions(&conn, 5).unwrap().is_empty());
3008 }
3009
3010 #[test]
3011 fn refresh_creates_question_tables_for_a_first_training_cycle() {
3012 let (conn, _, _) = qf::library();
3013 conn.execute_batch(
3014 "DROP TABLE face_learning_question_faces;
3015 DROP TABLE face_learning_questions;",
3016 )
3017 .unwrap();
3018
3019 let questions = refresh_identity_questions(&conn, &QuestionSelectionConfig::default())
3020 .expect("a promoted profile should create the question tables");
3021 assert_eq!(questions.len(), 1);
3022 assert_eq!(pending_identity_questions(&conn, 5).unwrap().len(), 1);
3023 }
3024
3025 #[test]
3029 fn v2_library_enforces_keys_through_the_public_paths() {
3030 use videre_core::face_learning::QuestionAnswer as Answer;
3031 let root = tempfile::tempdir().unwrap();
3032 let cache = tempfile::tempdir().unwrap();
3033 let ctx = videre_core::library::LibraryContext::new(root.path(), cache.path()).unwrap();
3034 let conn = videre_core::library_db::initialize(&ctx).unwrap();
3035 let keys_on: i64 = conn
3036 .query_row("PRAGMA foreign_keys", [], |row| row.get(0))
3037 .unwrap();
3038 assert_eq!(keys_on, 1, "an initialized library verifies enforcement");
3039
3040 conn.execute_batch(
3043 "INSERT INTO faces (id, hash, bbox, embedding, cluster_id, confirmed, det_score, blur) VALUES
3044 (1, 'k1', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0),
3045 (2, 'k2', '0,0,9,9', X'0000', 7, 0, 0.9, 600.0);
3046 INSERT INTO people (name, full_name) VALUES ('alice', 'Alice'), ('bob', 'Bob');",
3047 )
3048 .unwrap();
3049 assign(&conn, &[1], "Alice").unwrap();
3050 assign(&conn, &[2], "Bob").unwrap();
3051
3052 assert!(conn
3055 .execute(
3056 "INSERT INTO faces (hash,bbox,embedding,person_label,confirmed)
3057 VALUES ('k9','0,0,9,9',X'0000','ghost',1)",
3058 [],
3059 )
3060 .is_err());
3061 assert!(conn
3062 .execute(
3063 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
3064 VALUES (999, 1, 'subject', 0)",
3065 [],
3066 )
3067 .is_err());
3068
3069 conn.execute(
3071 "INSERT INTO face_learning_events (id, action_kind, decision_kind, outcome,
3072 embedding_model_id, feature_schema_version, target_identity,
3073 feature_snapshot_json, support_count)
3074 VALUES (1, 'assign_face', 'membership', 'positive', 'x/1', 1, 'alice', '{}', 0)",
3075 [],
3076 )
3077 .unwrap();
3078 conn.execute(
3079 "INSERT INTO face_learning_event_faces (event_id, face_id, role, ordinal)
3080 VALUES (1, 1, 'subject', 0)",
3081 [],
3082 )
3083 .unwrap();
3084
3085 delete_person_with_learning(&conn, "Alice").unwrap();
3088 let state: (i64, Option<String>) = conn
3089 .query_row(
3090 "SELECT confirmed, person_label FROM faces WHERE id = 1",
3091 [],
3092 |r| Ok((r.get(0)?, r.get(1)?)),
3093 )
3094 .unwrap();
3095 assert_eq!(state, (0, None));
3096
3097 let question = videre_core::face_learning::select_questions(
3099 &conn,
3100 &videre_core::face_learning::QuestionSelectionConfig::default(),
3101 )
3102 .unwrap();
3103 if !question.is_empty() {
3104 let stored =
3105 videre_core::face_learning::replace_pending_questions(&conn, &question).unwrap();
3106 conn.execute(
3107 "UPDATE face_learning_questions SET evidence_revision = 'stale' WHERE id = ?1",
3108 rusqlite::params![stored[0].id],
3109 )
3110 .unwrap();
3111 let context = TeachingContext {
3112 embedding_model_id: "x/1".into(),
3113 active_profile_id: None,
3114 };
3115 assert!(matches!(
3116 answer_question_with_learning(&conn, stored[0].id, Answer::Yes, &context),
3117 Err(Error::Conflict)
3118 ));
3119 }
3120
3121 videre_core::face_db::reset_all(&conn).unwrap();
3123 for table in [
3124 "face_learning_events",
3125 "face_learning_event_faces",
3126 "face_learning_questions",
3127 "face_learning_question_faces",
3128 "face_learning_profiles",
3129 ] {
3130 let n: i64 = conn
3131 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
3132 .unwrap();
3133 assert_eq!(n, 0, "{table} must be empty after reset");
3134 }
3135 let violations: i64 = conn
3136 .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
3137 r.get(0)
3138 })
3139 .unwrap();
3140 assert_eq!(violations, 0);
3141 }
3142
3143 #[test]
3144 fn yes_cannot_label_without_evidence() {
3145 let (conn, question_id, profile_id) = qf::library();
3146 conn.execute_batch(
3147 "CREATE TRIGGER abort_question_events
3148 BEFORE INSERT ON face_learning_events
3149 BEGIN SELECT RAISE(ABORT, 'injected event failure'); END;",
3150 )
3151 .unwrap();
3152 assert!(answer_question_with_learning(
3153 &conn,
3154 question_id,
3155 QuestionAnswer::Yes,
3156 &qf::context(profile_id)
3157 )
3158 .is_err());
3159
3160 let labeled: i64 = conn
3161 .query_row(
3162 "SELECT count(*) FROM faces WHERE id IN (10, 11) AND confirmed = 1",
3163 [],
3164 |row| row.get(0),
3165 )
3166 .unwrap();
3167 assert_eq!(labeled, 0, "yes cannot label without its evidence row");
3168
3169 let question = videre_core::face_learning::stored_question(&conn, question_id)
3170 .unwrap()
3171 .unwrap();
3172 assert_eq!(
3173 question.status,
3174 videre_core::face_learning::QuestionStatus::Pending
3175 );
3176 }
3177}