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