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