1use super::*;
2
3#[cfg(not(feature = "hnsw_clustering"))]
9const MAX_STAGE_B_INPUT: usize = 800;
10const MAX_POST_MERGE_CLUSTER_PAIRS: usize = 50_000;
11
12impl FaceProcessor {
13 pub fn run_clustering(
24 face_repo: &FaceRepo,
25 clustering_threshold: f32,
26 resolver_weights: crate::ml::ResolverWeights,
27 ) -> Result<usize, String> {
28 let mut assigned_to_existing = 0usize;
29 let mut queued_for_review = 0usize;
30 let strict_max_distance = clustering_threshold.clamp(0.15, 0.6);
31
32 let galleries = face_repo
34 .get_gallery_embeddings()
35 .map_err(|e| format!("Failed to load person galleries: {}", e))?;
36 let cluster_photo_rows = face_repo
37 .get_cluster_photo_ids()
38 .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
39 let cannot_merge = face_repo
40 .get_cannot_merge_map()
41 .map_err(|e| format!("Failed to load cannot-merge constraints: {}", e))?;
42
43 let mut cluster_photo_ids: std::collections::HashMap<i64, std::collections::HashSet<i64>> =
44 std::collections::HashMap::new();
45 for (cluster_id, photo_id) in cluster_photo_rows {
46 cluster_photo_ids
47 .entry(cluster_id)
48 .or_default()
49 .insert(photo_id);
50 }
51
52 let mut gallery_by_cluster: std::collections::HashMap<
54 i64,
55 Vec<(i64, crate::ml::FaceEmbedding)>,
56 > = std::collections::HashMap::new();
57 for g in galleries {
58 gallery_by_cluster
59 .entry(g.cluster_id)
60 .or_default()
61 .push((g.face_id, g.embedding));
62 }
63 let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
64 gallery_by_cluster.into_iter().collect();
65
66 let banding = crate::ml::BandingConfig::default();
67
68 let unclustered = face_repo
69 .get_unclustered_faces_with_photo_embeddings()
70 .map_err(|e| format!("Failed to get unclustered faces: {}", e))?;
71
72 for (face_id, photo_id, embedding) in &unclustered {
73 let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
76 let clusters_in_photo: Vec<i64> = cluster_photo_ids
77 .iter()
78 .filter(|(_, set)| set.contains(photo_id))
79 .map(|(cid, _)| *cid)
80 .collect();
81 for cid in &clusters_in_photo {
82 exclude.insert(*cid);
83 if let Some(forbidden) = cannot_merge.get(cid) {
84 for f in forbidden {
85 exclude.insert(*f);
86 }
87 }
88 }
89
90 let hits = crate::ml::retrieve_candidates(
91 embedding,
92 &gallery_vec,
93 5,
94 1.0 - strict_max_distance, &exclude,
96 );
97
98 let resolver_ctx = Self::build_resolver_context(face_repo, *photo_id, *face_id, &hits);
100 let reranked = crate::ml::rerank(&hits, &resolver_ctx, resolver_weights);
101 let band = crate::ml::retrieval::classify(&reranked, &banding);
102
103 match band {
104 crate::ml::ConfidenceBand::High { hit } => {
105 face_repo
106 .assign_face_to_cluster(*face_id, hit.cluster_id)
107 .map_err(|e| format!("Failed to assign face to cluster: {}", e))?;
108 cluster_photo_ids
109 .entry(hit.cluster_id)
110 .or_default()
111 .insert(*photo_id);
112 assigned_to_existing += 1;
113 }
114 crate::ml::ConfidenceBand::Ambiguous { top, runner_up } => {
115 let ambiguity = runner_up.as_ref().map(|r| top.score - r.score);
116 if let Err(e) =
117 face_repo.enqueue_review(*face_id, top.cluster_id, top.score, ambiguity)
118 {
119 tracing::warn!("Failed to enqueue review for face {}: {}", face_id, e);
120 }
121 queued_for_review += 1;
122 }
123 crate::ml::ConfidenceBand::Low => {
124 }
126 }
127 }
128
129 if queued_for_review > 0 {
130 tracing::info!(
131 "Queued {} ambiguous faces for user review",
132 queued_for_review
133 );
134 }
135
136 let unresolved = face_repo
138 .get_unclustered_faces_with_photo_embeddings()
139 .map_err(|e| format!("Failed to reload unresolved faces: {}", e))?;
140
141 if unresolved.is_empty() {
142 face_repo
143 .refresh_all_galleries()
144 .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
145 tracing::info!(
146 "Agglomerative clustering: assigned {} faces to existing galleries; no unresolved faces left",
147 assigned_to_existing
148 );
149 return Ok(0);
150 }
151
152 #[cfg(not(feature = "hnsw_clustering"))]
153 if unresolved.len() > MAX_STAGE_B_INPUT {
154 tracing::warn!(
159 "Skipping Stage B complete-link clustering: {} unresolved faces exceeds cap of {}. Enable `hnsw_clustering` to remove this cap.",
160 unresolved.len(),
161 MAX_STAGE_B_INPUT
162 );
163 face_repo
164 .refresh_all_galleries()
165 .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
166 let (rescued, queued) = Self::rescue_orphan_faces(face_repo)?;
167 tracing::info!(
168 "Clustering (Stage B skipped): {} to-existing, {} rescued, {} queued, from {} unresolved",
169 assigned_to_existing,
170 rescued,
171 queued,
172 unresolved.len()
173 );
174 return Ok(0);
175 }
176
177 let inputs: Vec<ClusterInput> = unresolved
178 .iter()
179 .map(|(face_id, photo_id, emb)| ClusterInput {
180 face_id: *face_id,
181 photo_id: *photo_id,
182 current_cluster_id: None,
183 embedding: emb.clone(),
184 })
185 .collect();
186
187 let negatives: std::collections::HashSet<(i64, i64)> = {
189 let mut stmt = face_repo
190 .conn
191 .prepare("SELECT face_id, not_cluster_id FROM face_negatives")
192 .map_err(|e| format!("Failed to load negatives: {}", e))?;
193 let rows = stmt
194 .query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)))
195 .map_err(|e| format!("Failed to query negatives: {}", e))?;
196 let mut set = std::collections::HashSet::new();
197 for row in rows {
198 let pair = row.map_err(|e| format!("Failed to read negative row: {}", e))?;
199 set.insert(pair);
200 }
201 set
202 };
203
204 let clusterer = FaceClusterer::new().with_max_distance(strict_max_distance);
205 let assignments = clusterer.cluster(&inputs, Some(&negatives));
206
207 let mut cluster_groups: HashMap<i32, Vec<i64>> = HashMap::new();
208 for (face_id, cluster_id) in assignments {
209 if cluster_id >= 0 {
210 cluster_groups.entry(cluster_id).or_default().push(face_id);
211 }
212 }
213
214 let mut clusters_created = 0usize;
215 for face_ids in cluster_groups.values() {
216 if face_ids.len() >= 2 {
217 face_repo
218 .create_cluster(face_ids)
219 .map_err(|e| format!("Failed to create cluster: {}", e))?;
220 clusters_created += 1;
221 }
222 }
223
224 face_repo
225 .refresh_all_galleries()
226 .map_err(|e| format!("Failed to refresh galleries: {}", e))?;
227
228 let merged = Self::merge_similar_clusters(face_repo)?;
233
234 let (rescued, queued) = Self::rescue_orphan_faces(face_repo)?;
239
240 let singletons_promoted = Self::promote_orphans_to_singletons(face_repo)?;
248
249 tracing::info!(
250 "Clustering: {} to-existing, {} new, merged {}, rescued {}, queued {}, singletons {}, from {} unresolved",
251 assigned_to_existing,
252 clusters_created,
253 merged,
254 rescued,
255 queued,
256 singletons_promoted,
257 unresolved.len()
258 );
259
260 Ok(clusters_created.saturating_sub(merged) + singletons_promoted)
261 }
262
263 fn rescue_orphan_faces(face_repo: &FaceRepo) -> Result<(usize, usize), String> {
278 const RESCUE_MIN_SIM: f32 = 0.45;
284
285 let rescue_banding = crate::ml::BandingConfig {
286 low_threshold: 0.45,
287 high_threshold: 0.60,
288 margin: 0.08,
289 };
290
291 let galleries = face_repo
292 .get_gallery_embeddings()
293 .map_err(|e| format!("Failed to load galleries for rescue: {}", e))?;
294
295 if galleries.is_empty() {
296 return Ok((0, 0));
298 }
299
300 let mut gallery_by_cluster: HashMap<i64, Vec<(i64, crate::ml::FaceEmbedding)>> =
301 HashMap::new();
302 for g in galleries {
303 gallery_by_cluster
304 .entry(g.cluster_id)
305 .or_default()
306 .push((g.face_id, g.embedding));
307 }
308 let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
309 gallery_by_cluster.into_iter().collect();
310
311 let cannot_merge = face_repo
312 .get_cannot_merge_map()
313 .map_err(|e| format!("Failed to load cannot-merge: {}", e))?;
314
315 let cluster_photo_rows = face_repo
316 .get_cluster_photo_ids()
317 .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
318 let mut cluster_photo_ids: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
319 for (cid, pid) in cluster_photo_rows {
320 cluster_photo_ids.entry(cid).or_default().insert(pid);
321 }
322
323 let orphans = face_repo
324 .get_unclustered_faces_with_photo_embeddings()
325 .map_err(|e| format!("Failed to load orphan faces: {}", e))?;
326
327 let mut rescued = 0usize;
328 let mut queued = 0usize;
329
330 for (face_id, photo_id, embedding) in &orphans {
331 let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
333 let clusters_in_photo: Vec<i64> = cluster_photo_ids
334 .iter()
335 .filter(|(_, set)| set.contains(photo_id))
336 .map(|(cid, _)| *cid)
337 .collect();
338 for cid in &clusters_in_photo {
339 exclude.insert(*cid);
340 if let Some(forbidden) = cannot_merge.get(cid) {
341 for f in forbidden {
342 exclude.insert(*f);
343 }
344 }
345 }
346
347 let hits = crate::ml::retrieve_candidates(
348 embedding,
349 &gallery_vec,
350 5,
351 RESCUE_MIN_SIM,
352 &exclude,
353 );
354 let band = crate::ml::retrieval::classify(&hits, &rescue_banding);
355
356 match band {
357 crate::ml::ConfidenceBand::High { hit } => {
358 if let Err(e) = face_repo.assign_face_to_cluster(*face_id, hit.cluster_id) {
359 tracing::warn!("rescue: assign_face_to_cluster failed: {}", e);
360 continue;
361 }
362 cluster_photo_ids
363 .entry(hit.cluster_id)
364 .or_default()
365 .insert(*photo_id);
366 rescued += 1;
367 }
368 crate::ml::ConfidenceBand::Ambiguous { top, runner_up } => {
369 let ambiguity = runner_up.as_ref().map(|r| top.score - r.score);
370 if let Err(e) =
371 face_repo.enqueue_review(*face_id, top.cluster_id, top.score, ambiguity)
372 {
373 tracing::warn!("rescue: enqueue_review failed: {}", e);
374 continue;
375 }
376 queued += 1;
377 }
378 crate::ml::ConfidenceBand::Low => {
379 }
381 }
382 }
383
384 Ok((rescued, queued))
385 }
386
387 fn promote_orphans_to_singletons(face_repo: &FaceRepo) -> Result<usize, String> {
397 let orphan_ids: Vec<i64> = {
398 let mut stmt = face_repo
399 .conn
400 .prepare(
401 "SELECT id FROM faces \
402 WHERE cluster_id IS NULL AND user_confirmed >= 0",
403 )
404 .map_err(|e| format!("Failed to query orphan faces: {}", e))?;
405 let rows = stmt
406 .query_map([], |r| r.get::<_, i64>(0))
407 .map_err(|e| format!("Failed to read orphan faces: {}", e))?;
408 let mut out = Vec::new();
409 for r in rows {
410 out.push(r.map_err(|e| format!("Failed to read orphan row: {}", e))?);
411 }
412 out
413 };
414
415 let mut made = 0usize;
416 for face_id in orphan_ids {
417 match face_repo.create_cluster(&[face_id]) {
418 Ok(_) => made += 1,
419 Err(e) => tracing::warn!(
420 "Could not promote orphan face {} to singleton cluster: {}",
421 face_id,
422 e
423 ),
424 }
425 }
426 Ok(made)
427 }
428
429 fn merge_similar_clusters(face_repo: &FaceRepo) -> Result<usize, String> {
443 const MERGE_THRESHOLD: f32 = 0.55;
446 const TOP_K_PAIRS: usize = 3;
447
448 let galleries = face_repo
449 .get_gallery_embeddings()
450 .map_err(|e| format!("Failed to load galleries: {}", e))?;
451
452 let mut gallery_by_cluster: HashMap<i64, Vec<crate::ml::FaceEmbedding>> = HashMap::new();
453 for g in galleries {
454 gallery_by_cluster
455 .entry(g.cluster_id)
456 .or_default()
457 .push(g.embedding);
458 }
459
460 let cannot_merge = face_repo
461 .get_cannot_merge_map()
462 .map_err(|e| format!("Failed to load cannot-merge: {}", e))?;
463
464 let cluster_photo_rows = face_repo
465 .get_cluster_photo_ids()
466 .map_err(|e| format!("Failed to load cluster-photo map: {}", e))?;
467 let mut cluster_photos: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
468 for (cid, pid) in cluster_photo_rows {
469 cluster_photos.entry(cid).or_default().insert(pid);
470 }
471
472 let cluster_ids: Vec<i64> = gallery_by_cluster.keys().copied().collect();
473 let pair_count = post_merge_pair_count(cluster_ids.len());
474 if pair_count > MAX_POST_MERGE_CLUSTER_PAIRS {
475 tracing::info!(
476 "Skipping post-pass face-cluster merge: {} cluster pairs exceeds cap {}",
477 pair_count,
478 MAX_POST_MERGE_CLUSTER_PAIRS
479 );
480 return Ok(0);
481 }
482
483 let mut candidates: Vec<(f32, i64, i64)> = Vec::new();
484
485 for i in 0..cluster_ids.len() {
486 for j in (i + 1)..cluster_ids.len() {
487 let a = cluster_ids[i];
488 let b = cluster_ids[j];
489
490 if cannot_merge.get(&a).is_some_and(|set| set.contains(&b)) {
491 continue;
492 }
493
494 let photos_a = cluster_photos.get(&a);
495 let photos_b = cluster_photos.get(&b);
496 let shares_photo = match (photos_a, photos_b) {
497 (Some(pa), Some(pb)) => pa.iter().any(|p| pb.contains(p)),
498 _ => false,
499 };
500 if shares_photo {
501 continue;
502 }
503
504 let ga = gallery_by_cluster
505 .get(&a)
506 .map(|v| v.as_slice())
507 .unwrap_or(&[]);
508 let gb = gallery_by_cluster
509 .get(&b)
510 .map(|v| v.as_slice())
511 .unwrap_or(&[]);
512 if ga.is_empty() || gb.is_empty() {
513 continue;
514 }
515
516 let mut sims: Vec<f32> = Vec::with_capacity(ga.len() * gb.len());
517 for ea in ga {
518 for eb in gb {
519 let s = ea.cosine_similarity(eb);
520 if s.is_nan() {
521 continue;
522 }
523 sims.push(s);
524 }
525 }
526 if sims.is_empty() {
527 continue;
528 }
529 sims.sort_by(|x, y| y.total_cmp(x));
530 let k = TOP_K_PAIRS.min(sims.len());
531 let mean_top_k: f32 = sims.iter().take(k).sum::<f32>() / k as f32;
532
533 if mean_top_k >= MERGE_THRESHOLD {
534 candidates.push((mean_top_k, a, b));
535 }
536 }
537 }
538
539 candidates.sort_by(|x, y| y.0.total_cmp(&x.0));
540
541 let mut parent: HashMap<i64, i64> = cluster_ids.iter().map(|&c| (c, c)).collect();
543 fn find(parent: &mut HashMap<i64, i64>, mut x: i64) -> i64 {
544 loop {
545 let p = *parent.get(&x).unwrap_or(&x);
546 if p == x {
547 return x;
548 }
549 let pp = *parent.get(&p).unwrap_or(&p);
551 parent.insert(x, pp);
552 x = pp;
553 }
554 }
555
556 let mut merged_count = 0usize;
557 for (score, a, b) in candidates {
558 let ra = find(&mut parent, a);
559 let rb = find(&mut parent, b);
560 if ra == rb {
561 continue;
562 }
563 let (survivor, casualty) = if ra < rb { (ra, rb) } else { (rb, ra) };
565 match face_repo.merge_clusters(casualty, survivor) {
566 Ok(_) => {
567 parent.insert(casualty, survivor);
568 merged_count += 1;
569 tracing::info!(
570 "Post-pass merge: cluster {} -> {} (score {:.3})",
571 casualty,
572 survivor,
573 score
574 );
575 }
576 Err(e) => {
577 tracing::warn!("merge_similar_clusters: merge failed {}: {}", casualty, e);
578 }
579 }
580 }
581
582 Ok(merged_count)
583 }
584
585 pub(crate) fn save_face_crop(
587 aligned_face: &image::RgbImage,
588 path: &Path,
589 ) -> Result<(), image::ImageError> {
590 let dynamic = image::DynamicImage::ImageRgb8(aligned_face.clone());
591 let resized = dynamic.resize_exact(80, 80, image::imageops::FilterType::Lanczos3);
592 resized.save(path)
593 }
594
595 pub fn regenerate_missing_crops(drive_path: &Path) -> Result<usize, String> {
597 let db = Database::open_for_drive(drive_path)
598 .map_err(|e| format!("Failed to open database: {}", e))?;
599 let face_repo = FaceRepo::new(&db.conn);
600
601 let faces_dir = Self::faces_dir(drive_path);
602 if let Err(e) = std::fs::create_dir_all(&faces_dir) {
603 return Err(format!("Failed to create faces directory: {}", e));
604 }
605
606 let all_faces = face_repo
607 .get_all_faces_with_paths()
608 .map_err(|e| format!("Failed to get faces: {}", e))?;
609
610 let mut regenerated = 0usize;
611 for (face_id, file_path, orientation, bbox_x, bbox_y, bbox_w, bbox_h) in &all_faces {
612 let crop_path = faces_dir.join(format!("{}.jpg", face_id));
613 if crop_path.exists() {
614 continue;
615 }
616
617 let full_path =
618 match crate::services::path_util::safe_join_relative(drive_path, file_path) {
619 Ok(path) => path,
620 Err(e) => {
621 tracing::trace!(
622 "regenerate face crop skipped invalid photo path {}: {}",
623 file_path,
624 e
625 );
626 continue;
627 }
628 };
629 let img = match image::open(&full_path) {
630 Ok(img) => apply_exif_orientation(img, *orientation),
631 Err(_) => continue,
632 };
633
634 let (img_w, img_h) = (img.width() as f32, img.height() as f32);
635
636 let px = (bbox_x * img_w) as u32;
637 let py = (bbox_y * img_h) as u32;
638 let pw = (bbox_w * img_w) as u32;
639 let ph = (bbox_h * img_h) as u32;
640
641 let pad_x = (pw as f32 * 0.2) as u32;
642 let pad_y = (ph as f32 * 0.2) as u32;
643 let crop_x = px.saturating_sub(pad_x);
644 let crop_y = py.saturating_sub(pad_y);
645 let crop_w = (pw + 2 * pad_x).min(img.width() - crop_x);
646 let crop_h = (ph + 2 * pad_y).min(img.height() - crop_y);
647
648 if crop_w == 0 || crop_h == 0 {
649 continue;
650 }
651
652 let cropped = img.crop_imm(crop_x, crop_y, crop_w, crop_h);
653 let resized = cropped.resize_exact(80, 80, image::imageops::FilterType::Lanczos3);
654 if resized.save(&crop_path).is_ok() {
655 regenerated += 1;
656 }
657 }
658
659 if regenerated > 0 {
660 tracing::info!("Regenerated {} missing face crop thumbnails", regenerated);
661 }
662
663 Ok(regenerated)
664 }
665
666 pub fn faces_dir(drive_path: &Path) -> PathBuf {
668 drive_path.join(".photovault").join("faces")
669 }
670
671 pub(crate) fn stream_assign_existing_clusters(
682 face_repo: &FaceRepo,
683 clustering_threshold: f32,
684 resolver_weights: crate::ml::ResolverWeights,
685 ) -> Result<usize, String> {
686 let strict_max_distance = clustering_threshold.clamp(0.15, 0.6);
687
688 let galleries = face_repo
689 .get_gallery_embeddings()
690 .map_err(|e| format!("stream Stage A: load galleries: {}", e))?;
691 if galleries.is_empty() {
692 return Ok(0);
695 }
696
697 let cluster_photo_rows = face_repo
698 .get_cluster_photo_ids()
699 .map_err(|e| format!("stream Stage A: cluster-photo map: {}", e))?;
700 let cannot_merge = face_repo
701 .get_cannot_merge_map()
702 .map_err(|e| format!("stream Stage A: cannot-merge: {}", e))?;
703
704 let mut cluster_photo_ids: HashMap<i64, std::collections::HashSet<i64>> = HashMap::new();
705 for (cid, pid) in cluster_photo_rows {
706 cluster_photo_ids.entry(cid).or_default().insert(pid);
707 }
708
709 let mut gallery_by_cluster: HashMap<i64, Vec<(i64, crate::ml::FaceEmbedding)>> =
710 HashMap::new();
711 for g in galleries {
712 gallery_by_cluster
713 .entry(g.cluster_id)
714 .or_default()
715 .push((g.face_id, g.embedding));
716 }
717 let gallery_vec: Vec<(i64, Vec<(i64, crate::ml::FaceEmbedding)>)> =
718 gallery_by_cluster.into_iter().collect();
719
720 let banding = crate::ml::BandingConfig::default();
721 let unclustered = face_repo
722 .get_unclustered_faces_with_photo_embeddings()
723 .map_err(|e| format!("stream Stage A: get unclustered: {}", e))?;
724
725 let mut assigned = 0usize;
726 for (face_id, photo_id, embedding) in &unclustered {
727 let mut exclude: std::collections::HashSet<i64> = std::collections::HashSet::new();
728 let clusters_in_photo: Vec<i64> = cluster_photo_ids
729 .iter()
730 .filter(|(_, set)| set.contains(photo_id))
731 .map(|(cid, _)| *cid)
732 .collect();
733 for cid in &clusters_in_photo {
734 exclude.insert(*cid);
735 if let Some(forbidden) = cannot_merge.get(cid) {
736 for f in forbidden {
737 exclude.insert(*f);
738 }
739 }
740 }
741
742 let hits = crate::ml::retrieve_candidates(
743 embedding,
744 &gallery_vec,
745 5,
746 1.0 - strict_max_distance,
747 &exclude,
748 );
749 let resolver_ctx = Self::build_resolver_context(face_repo, *photo_id, *face_id, &hits);
750 let reranked = crate::ml::rerank(&hits, &resolver_ctx, resolver_weights);
751 let band = crate::ml::retrieval::classify(&reranked, &banding);
752
753 match band {
754 crate::ml::ConfidenceBand::High { hit }
755 if face_repo
756 .assign_face_to_cluster(*face_id, hit.cluster_id)
757 .is_ok() =>
758 {
759 cluster_photo_ids
760 .entry(hit.cluster_id)
761 .or_default()
762 .insert(*photo_id);
763 assigned += 1;
764 }
765 _ => {
766 }
768 }
769 }
770
771 Ok(assigned)
772 }
773}
774
775fn post_merge_pair_count(cluster_count: usize) -> usize {
776 cluster_count.saturating_mul(cluster_count.saturating_sub(1)) / 2
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn post_merge_pair_count_bounds_quadratic_pass() {
785 assert_eq!(post_merge_pair_count(0), 0);
786 assert_eq!(post_merge_pair_count(1), 0);
787 assert_eq!(post_merge_pair_count(316), 49_770);
788 assert_eq!(post_merge_pair_count(317), 50_086);
789 }
790}