Skip to main content

smriti/db/face_repo/
write.rs

1//! Write/mutation methods for FaceRepo.
2
3use rusqlite::{params, OptionalExtension, Result as SqliteResult};
4
5use super::FaceRepo;
6
7impl<'a> FaceRepo<'a> {
8    /// Reset faces_processed flags when no faces were actually detected.
9    ///
10    /// This handles the case where a prior run marked all photos as processed
11    /// but failed to actually detect faces (e.g., model loading error).
12    pub fn reset_if_no_faces(&self) -> SqliteResult<usize> {
13        let face_count: i64 = self
14            .conn
15            .query_row("SELECT COUNT(*) FROM faces", [], |row| row.get(0))?;
16        let processed_count: i64 = self.conn.query_row(
17            "SELECT COUNT(*) FROM photos WHERE faces_processed = TRUE",
18            [],
19            |row| row.get(0),
20        )?;
21
22        if face_count == 0 && processed_count > 0 {
23            let reset = self.conn.execute(
24                "UPDATE photos SET faces_processed = FALSE WHERE faces_processed = TRUE",
25                [],
26            )?;
27            tracing::info!(
28                "Reset faces_processed flag on {} photos (no faces were actually detected)",
29                reset
30            );
31            Ok(reset)
32        } else {
33            Ok(0)
34        }
35    }
36
37    /// Name a cluster (set the person's name)
38    pub fn name_cluster(&self, cluster_id: i64, name: &str) -> SqliteResult<()> {
39        self.conn.execute(
40            "UPDATE face_clusters SET name = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
41            params![name, cluster_id],
42        )?;
43        Ok(())
44    }
45
46    /// Delete a face cluster — "this isn't a real person" gesture.
47    ///
48    /// Faces previously assigned to this cluster have their `cluster_id`
49    /// set to NULL so a future re-clustering pass can pick them up
50    /// again. Inferred-identity links and gallery embeddings rooted at
51    /// the cluster cascade away (FK ON DELETE CASCADE in the schema).
52    pub fn delete_cluster(&self, cluster_id: i64) -> SqliteResult<()> {
53        let tx = self.conn.unchecked_transaction()?;
54        tx.execute(
55            "UPDATE faces SET cluster_id = NULL WHERE cluster_id = ?1",
56            params![cluster_id],
57        )?;
58        tx.execute(
59            "DELETE FROM photo_inferred_identities WHERE cluster_id = ?1",
60            params![cluster_id],
61        )?;
62        tx.execute(
63            "DELETE FROM person_gallery_embeddings WHERE cluster_id = ?1",
64            params![cluster_id],
65        )?;
66        tx.execute(
67            "DELETE FROM face_clusters WHERE id = ?1",
68            params![cluster_id],
69        )?;
70        tx.commit()
71    }
72
73    /// Assign an existing face to an existing cluster and update cluster metadata.
74    pub fn assign_face_to_cluster(&self, face_id: i64, cluster_id: i64) -> SqliteResult<()> {
75        let tx = self.conn.unchecked_transaction()?;
76
77        tx.execute(
78            "UPDATE faces SET cluster_id = ?1 WHERE id = ?2",
79            params![cluster_id, face_id],
80        )?;
81
82        Self::refresh_cluster_stats_tx(&tx, cluster_id)?;
83        Self::refresh_gallery_tx(&tx, cluster_id)?;
84
85        tx.commit()
86    }
87
88    /// Create a new face cluster from a set of face IDs
89    pub fn create_cluster(&self, face_ids: &[i64]) -> SqliteResult<i64> {
90        let tx = self.conn.unchecked_transaction()?;
91
92        tx.execute(
93            r#"
94            INSERT INTO face_clusters (face_count, photo_count)
95            VALUES (0, 0)
96            "#,
97            [],
98        )?;
99
100        let cluster_id = tx.last_insert_rowid();
101
102        // Assign faces to this cluster
103        for face_id in face_ids {
104            tx.execute(
105                "UPDATE faces SET cluster_id = ?1 WHERE id = ?2",
106                params![cluster_id, face_id],
107            )?;
108        }
109
110        Self::refresh_cluster_stats_tx(&tx, cluster_id)?;
111        Self::refresh_gallery_tx(&tx, cluster_id)?;
112
113        tx.commit()?;
114
115        Ok(cluster_id)
116    }
117
118    /// Merge source cluster into target cluster
119    ///
120    /// Moves all faces from source to target, updates counts, deletes source.
121    /// Refuses if a cannot-merge constraint exists between the two clusters.
122    pub fn merge_clusters(&self, source_id: i64, target_id: i64) -> SqliteResult<()> {
123        let blocked: i64 = self.conn.query_row(
124            r#"
125            SELECT COUNT(*) FROM cluster_cannot_merge
126            WHERE (cluster_a_id = ?1 AND cluster_b_id = ?2)
127               OR (cluster_a_id = ?2 AND cluster_b_id = ?1)
128            "#,
129            params![source_id, target_id],
130            |row| row.get(0),
131        )?;
132        if blocked > 0 {
133            tracing::warn!(
134                "Refusing to merge clusters {} and {}: user marked them different",
135                source_id,
136                target_id
137            );
138            return Ok(());
139        }
140
141        let tx = self.conn.unchecked_transaction()?;
142
143        // Preserve names: if the target is unnamed but the source has a name,
144        // carry the name over so we never lose a user-assigned label.
145        let target_name: Option<String> = tx
146            .query_row(
147                "SELECT name FROM face_clusters WHERE id = ?1",
148                params![target_id],
149                |row| row.get(0),
150            )
151            .ok()
152            .flatten();
153        if target_name.is_none() {
154            let source_name: Option<String> = tx
155                .query_row(
156                    "SELECT name FROM face_clusters WHERE id = ?1",
157                    params![source_id],
158                    |row| row.get(0),
159                )
160                .ok()
161                .flatten();
162            if let Some(name) = source_name {
163                tx.execute(
164                    "UPDATE face_clusters SET name = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
165                    params![name, target_id],
166                )?;
167            }
168        }
169
170        // Move all faces from source to target
171        tx.execute(
172            "UPDATE faces SET cluster_id = ?1 WHERE cluster_id = ?2",
173            params![target_id, source_id],
174        )?;
175
176        // Move inferred identities from source to target (dedupe on unique constraint).
177        tx.execute(
178            r#"
179            INSERT OR IGNORE INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
180            SELECT photo_id, ?1, source_photo_id, confidence
181            FROM photo_inferred_identities
182            WHERE cluster_id = ?2
183            "#,
184            params![target_id, source_id],
185        )?;
186
187        Self::refresh_cluster_stats_tx(&tx, target_id)?;
188        Self::refresh_gallery_tx(&tx, target_id)?;
189
190        // Delete source cluster
191        tx.execute(
192            "DELETE FROM face_clusters WHERE id = ?1",
193            params![source_id],
194        )?;
195
196        tx.commit()
197    }
198
199    /// Load all cannot-merge pairs (returns them in both directions for easy lookup).
200    pub fn get_cannot_merge_map(
201        &self,
202    ) -> SqliteResult<std::collections::HashMap<i64, std::collections::HashSet<i64>>> {
203        let mut stmt = self
204            .conn
205            .prepare("SELECT cluster_a_id, cluster_b_id FROM cluster_cannot_merge")?;
206        let rows = stmt.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))?;
207
208        let mut map: std::collections::HashMap<i64, std::collections::HashSet<i64>> =
209            std::collections::HashMap::new();
210        for row in rows {
211            let (a, b) = row?;
212            map.entry(a).or_default().insert(b);
213            map.entry(b).or_default().insert(a);
214        }
215        Ok(map)
216    }
217
218    /// Add a face to the review queue with its top candidate cluster.
219    ///
220    /// Idempotent via UNIQUE(face_id, candidate_cluster_id). Updates score
221    /// and ambiguity on conflict.
222    pub fn enqueue_review(
223        &self,
224        face_id: i64,
225        candidate_cluster_id: i64,
226        score: f32,
227        ambiguity: Option<f32>,
228    ) -> SqliteResult<()> {
229        self.conn.execute(
230            r#"
231            INSERT INTO face_review_queue (face_id, candidate_cluster_id, score, ambiguity)
232            VALUES (?1, ?2, ?3, ?4)
233            ON CONFLICT(face_id, candidate_cluster_id) DO UPDATE SET
234                score = excluded.score,
235                ambiguity = excluded.ambiguity,
236                resolved_at = NULL,
237                resolved_as = NULL
238            "#,
239            params![face_id, candidate_cluster_id, score, ambiguity],
240        )?;
241        Ok(())
242    }
243
244    /// User confirmed this face is the same person as the candidate cluster.
245    ///
246    /// Atomic: assigns the face, marks user_confirmed=1, adds the face to the
247    /// cluster's gallery with source='user_confirmed' (sticky), resolves the
248    /// queue entry, refreshes cluster stats.
249    pub fn resolve_review_same(&self, queue_id: i64) -> SqliteResult<()> {
250        let tx = self.conn.unchecked_transaction()?;
251
252        let (face_id, candidate_cluster_id): (i64, i64) = tx.query_row(
253            "SELECT face_id, candidate_cluster_id FROM face_review_queue WHERE id = ?1",
254            params![queue_id],
255            |row| Ok((row.get(0)?, row.get(1)?)),
256        )?;
257
258        tx.execute(
259            "UPDATE faces SET cluster_id = ?1, user_confirmed = 1 WHERE id = ?2",
260            params![candidate_cluster_id, face_id],
261        )?;
262
263        let embedding_bytes: Vec<u8> = tx.query_row(
264            "SELECT embedding FROM faces WHERE id = ?1",
265            params![face_id],
266            |row| row.get(0),
267        )?;
268        let confidence: Option<f32> = tx
269            .query_row(
270                "SELECT confidence FROM faces WHERE id = ?1",
271                params![face_id],
272                |row| row.get(0),
273            )
274            .ok();
275
276        tx.execute(
277            r#"
278            INSERT INTO person_gallery_embeddings
279                (cluster_id, face_id, embedding, quality_score, source)
280            VALUES (?1, ?2, ?3, ?4, 'user_confirmed')
281            ON CONFLICT(cluster_id, face_id) DO UPDATE SET
282                source = 'user_confirmed',
283                quality_score = excluded.quality_score
284            "#,
285            params![
286                candidate_cluster_id,
287                face_id,
288                embedding_bytes,
289                confidence.unwrap_or(0.0),
290            ],
291        )?;
292
293        tx.execute(
294            "UPDATE face_review_queue SET resolved_at = CURRENT_TIMESTAMP, resolved_as = 'same' WHERE id = ?1",
295            params![queue_id],
296        )?;
297
298        Self::refresh_cluster_stats_tx(&tx, candidate_cluster_id)?;
299
300        tx.commit()
301    }
302
303    /// User rejected this candidate: the face is NOT the candidate cluster.
304    ///
305    /// - Marks the face with user_confirmed = -1 (rejected for this candidate).
306    /// - If the face was previously assigned to a different cluster, records
307    ///   a cluster_cannot_merge pair.
308    /// - Resolves the queue entry.
309    pub fn resolve_review_different(&self, queue_id: i64) -> SqliteResult<()> {
310        let tx = self.conn.unchecked_transaction()?;
311
312        let (face_id, candidate_cluster_id): (i64, i64) = tx.query_row(
313            "SELECT face_id, candidate_cluster_id FROM face_review_queue WHERE id = ?1",
314            params![queue_id],
315            |row| Ok((row.get(0)?, row.get(1)?)),
316        )?;
317
318        let prior_cluster: Option<i64> = tx
319            .query_row(
320                "SELECT cluster_id FROM faces WHERE id = ?1",
321                params![face_id],
322                |row| row.get(0),
323            )
324            .ok();
325
326        tx.execute(
327            "UPDATE faces SET user_confirmed = -1 WHERE id = ?1",
328            params![face_id],
329        )?;
330
331        if let Some(prior) = prior_cluster {
332            if prior != candidate_cluster_id {
333                let (a, b) = if prior < candidate_cluster_id {
334                    (prior, candidate_cluster_id)
335                } else {
336                    (candidate_cluster_id, prior)
337                };
338                tx.execute(
339                    "INSERT OR IGNORE INTO cluster_cannot_merge (cluster_a_id, cluster_b_id) VALUES (?1, ?2)",
340                    params![a, b],
341                )?;
342            }
343        }
344
345        tx.execute(
346            "UPDATE face_review_queue SET resolved_at = CURRENT_TIMESTAMP, resolved_as = 'different' WHERE id = ?1",
347            params![queue_id],
348        )?;
349
350        tx.commit()
351    }
352
353    /// User skipped this item: mark resolved_as = 'skipped' so it doesn't
354    /// resurface in the next review session (but can be re-queued on a
355    /// future scan if the situation changes).
356    pub fn resolve_review_skip(&self, queue_id: i64) -> SqliteResult<()> {
357        let updated = self.conn.execute(
358            "UPDATE face_review_queue SET resolved_at = CURRENT_TIMESTAMP, resolved_as = 'skipped' WHERE id = ?1",
359            params![queue_id],
360        )?;
361        if updated == 0 {
362            return Err(rusqlite::Error::QueryReturnedNoRows);
363        }
364        Ok(())
365    }
366
367    /// Confirm a face: set user_confirmed=1. Add to gallery if not already.
368    pub fn confirm_face(&self, face_id: i64) -> SqliteResult<()> {
369        let tx = self.conn.unchecked_transaction()?;
370
371        tx.execute(
372            "UPDATE faces SET user_confirmed = 1 WHERE id = ?1",
373            params![face_id],
374        )?;
375
376        let (cluster_id, embedding_bytes, confidence): (i64, Vec<u8>, Option<f32>) = tx.query_row(
377            "SELECT cluster_id, embedding, confidence FROM faces WHERE id = ?1",
378            params![face_id],
379            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
380        )?;
381
382        tx.execute(
383            r#"
384            INSERT INTO person_gallery_embeddings
385                (cluster_id, face_id, embedding, quality_score, source)
386            VALUES (?1, ?2, ?3, ?4, 'user_confirmed')
387            ON CONFLICT(cluster_id, face_id) DO UPDATE SET
388                source = 'user_confirmed',
389                quality_score = excluded.quality_score
390            "#,
391            params![
392                cluster_id,
393                face_id,
394                embedding_bytes,
395                confidence.unwrap_or(0.0)
396            ],
397        )?;
398
399        Self::refresh_cluster_stats_tx(&tx, cluster_id)?;
400        Self::refresh_gallery_tx(&tx, cluster_id)?;
401
402        tx.commit()
403    }
404
405    /// Reject face to unknown: set cluster_id=NULL, user_confirmed=0. Write negative.
406    pub fn reject_face_to_unknown(&self, face_id: i64, prev_cluster_id: i64) -> SqliteResult<()> {
407        let tx = self.conn.unchecked_transaction()?;
408
409        tx.execute(
410            "INSERT OR IGNORE INTO face_negatives (face_id, not_cluster_id) VALUES (?1, ?2)",
411            params![face_id, prev_cluster_id],
412        )?;
413
414        tx.execute(
415            "UPDATE faces SET cluster_id = NULL, user_confirmed = 0 WHERE id = ?1",
416            params![face_id],
417        )?;
418
419        Self::refresh_cluster_stats_tx(&tx, prev_cluster_id)?;
420        Self::refresh_gallery_tx(&tx, prev_cluster_id)?;
421
422        tx.commit()
423    }
424
425    /// Hide face: set cluster_id=NULL, user_confirmed=-1. Excluded from future clustering.
426    pub fn hide_face(&self, face_id: i64) -> SqliteResult<()> {
427        let tx = self.conn.unchecked_transaction()?;
428
429        let cluster_id: Option<i64> = tx
430            .query_row(
431                "SELECT cluster_id FROM faces WHERE id = ?1",
432                params![face_id],
433                |row| row.get(0),
434            )
435            .optional()?
436            .ok_or(rusqlite::Error::QueryReturnedNoRows)?;
437
438        tx.execute(
439            "UPDATE faces SET cluster_id = NULL, user_confirmed = -1 WHERE id = ?1",
440            params![face_id],
441        )?;
442
443        if let Some(cid) = cluster_id {
444            Self::refresh_cluster_stats_tx(&tx, cid)?;
445            Self::refresh_gallery_tx(&tx, cid)?;
446        }
447
448        tx.commit()
449    }
450
451    /// Reassign a face to a different cluster. Marks user_confirmed=1, writes negative
452    /// against the old cluster_id so it won't come back.
453    pub fn reassign_face(
454        &self,
455        face_id: i64,
456        new_cluster_id: i64,
457        old_cluster_id: i64,
458    ) -> SqliteResult<()> {
459        let tx = self.conn.unchecked_transaction()?;
460
461        tx.execute(
462            "INSERT OR IGNORE INTO face_negatives (face_id, not_cluster_id) VALUES (?1, ?2)",
463            params![face_id, old_cluster_id],
464        )?;
465
466        tx.execute(
467            "UPDATE faces SET cluster_id = ?1, user_confirmed = 1 WHERE id = ?2",
468            params![new_cluster_id, face_id],
469        )?;
470
471        let embedding_bytes: Vec<u8> = tx.query_row(
472            "SELECT embedding FROM faces WHERE id = ?1",
473            params![face_id],
474            |row| row.get(0),
475        )?;
476        let confidence: Option<f32> = tx
477            .query_row(
478                "SELECT confidence FROM faces WHERE id = ?1",
479                params![face_id],
480                |row| row.get(0),
481            )
482            .ok();
483
484        tx.execute(
485            r#"
486            INSERT INTO person_gallery_embeddings
487                (cluster_id, face_id, embedding, quality_score, source)
488            VALUES (?1, ?2, ?3, ?4, 'user_confirmed')
489            ON CONFLICT(cluster_id, face_id) DO UPDATE SET
490                source = 'user_confirmed',
491                quality_score = excluded.quality_score
492            "#,
493            params![
494                new_cluster_id,
495                face_id,
496                embedding_bytes,
497                confidence.unwrap_or(0.0)
498            ],
499        )?;
500
501        Self::refresh_cluster_stats_tx(&tx, old_cluster_id)?;
502        Self::refresh_gallery_tx(&tx, old_cluster_id)?;
503        Self::refresh_cluster_stats_tx(&tx, new_cluster_id)?;
504        Self::refresh_gallery_tx(&tx, new_cluster_id)?;
505
506        tx.commit()
507    }
508
509    /// Confirm a currently-unassigned face into a cluster (used by K-similar flow).
510    pub fn confirm_face_to_cluster(&self, face_id: i64, cluster_id: i64) -> SqliteResult<()> {
511        let tx = self.conn.unchecked_transaction()?;
512
513        tx.execute(
514            "UPDATE faces SET cluster_id = ?1, user_confirmed = 1 WHERE id = ?2",
515            params![cluster_id, face_id],
516        )?;
517
518        let embedding_bytes: Vec<u8> = tx.query_row(
519            "SELECT embedding FROM faces WHERE id = ?1",
520            params![face_id],
521            |row| row.get(0),
522        )?;
523        let confidence: Option<f32> = tx
524            .query_row(
525                "SELECT confidence FROM faces WHERE id = ?1",
526                params![face_id],
527                |row| row.get(0),
528            )
529            .ok();
530
531        tx.execute(
532            r#"
533            INSERT INTO person_gallery_embeddings
534                (cluster_id, face_id, embedding, quality_score, source)
535            VALUES (?1, ?2, ?3, ?4, 'user_confirmed')
536            ON CONFLICT(cluster_id, face_id) DO UPDATE SET
537                source = 'user_confirmed',
538                quality_score = excluded.quality_score
539            "#,
540            params![
541                cluster_id,
542                face_id,
543                embedding_bytes,
544                confidence.unwrap_or(0.0)
545            ],
546        )?;
547
548        Self::refresh_cluster_stats_tx(&tx, cluster_id)?;
549        Self::refresh_gallery_tx(&tx, cluster_id)?;
550
551        tx.commit()
552    }
553
554    /// Reverse a prior review resolution (for undo).
555    pub fn unresolve_review(&self, queue_id: i64) -> SqliteResult<()> {
556        let tx = self.conn.unchecked_transaction()?;
557
558        let resolved_as: Option<String> = tx
559            .query_row(
560                "SELECT resolved_as FROM face_review_queue WHERE id = ?1",
561                params![queue_id],
562                |row| row.get(0),
563            )
564            .ok();
565
566        let (face_id, candidate_cluster_id): (i64, i64) = tx.query_row(
567            "SELECT face_id, candidate_cluster_id FROM face_review_queue WHERE id = ?1",
568            params![queue_id],
569            |row| Ok((row.get(0)?, row.get(1)?)),
570        )?;
571
572        match resolved_as.as_deref() {
573            Some("same") => {
574                // Roll back cluster assignment + gallery insert.
575                tx.execute(
576                    "UPDATE faces SET cluster_id = NULL, user_confirmed = 0 WHERE id = ?1",
577                    params![face_id],
578                )?;
579                tx.execute(
580                    "DELETE FROM person_gallery_embeddings WHERE cluster_id = ?1 AND face_id = ?2 AND source = 'user_confirmed'",
581                    params![candidate_cluster_id, face_id],
582                )?;
583                Self::refresh_cluster_stats_tx(&tx, candidate_cluster_id)?;
584            }
585            Some("different") => {
586                tx.execute(
587                    "UPDATE faces SET user_confirmed = 0 WHERE id = ?1",
588                    params![face_id],
589                )?;
590                // Note: we don't remove the cannot_merge row; user may still
591                // want that constraint. If they didn't, they can merge manually.
592            }
593            _ => {}
594        }
595
596        tx.execute(
597            "UPDATE face_review_queue SET resolved_at = NULL, resolved_as = NULL WHERE id = ?1",
598            params![queue_id],
599        )?;
600
601        tx.commit()
602    }
603}