# Face to Identity Binding Flow

> Version: V4.0 | Date: 2026-04-28
> Architecture: Two-layer (Face → Identity)
> Related: [FILE_IDENTITIES_TABLE_SPEC.md](./FILE_IDENTITIES_TABLE_SPEC.md)

---

## Overview

V4.0 架構實現 Face → Identity 直接綁定，移除 person_id 中間層。

### Key Principles

| Principle | Description |
|-----------|-------------|
| **Direct Binding** | Face 直接綁定到 Identity，無中間層 |
| **One-to-Many Reference** | Identity 擁有多個 Reference Vectors |
| **N:N File-Identity** | Identity 可跨多個 File |
| **Auto Chunk Binding** | Chunk 通過時間對齊自動綁定 |

---

## Data Model

```
┌─────────────────┐
│  face_detections│
├─────────────────┤
│ id              │
│ file_uuid  ─────┼───┐
│ frame           │   │
│ timestamp       │   │
│ trace_id        │   │
│ pose_angle      │   │
│ confidence      │   │
│ embedding (512) │   │
│ identity_id ────┼───┼──┐
└─────────────────┘   │  │
                      │  │
┌─────────────────┐   │  │
│     files        │   │  │
├─────────────────┤   │  │
│ uuid ◄──────────┼───┘  │
│ file_name       │      │
│ duration        │      │
└─────────────────┘      │
                         │
┌─────────────────┐      │
│  identities     │      │
├─────────────────┤      │
│ id ◄────────────┼──────┘
│ uuid            │
│ name            │
│ source          │
│ face_embedding  │ (reference vector)
│ reference_data  │ (JSONB, multiple vectors)
└─────────────────┘
         │
         │ N:N
         ▼
┌─────────────────┐
│ file_identities │
├─────────────────┤
│ file_uuid       │
│ identity_id     │
│ face_count      │
│ speaker_count   │
│ confidence      │
└─────────────────┘
```

---

## Binding Workflows

### 1. Manual Registration (New Identity)

**Trigger**: User selects face(s) and assigns name

```
User Selection
     │
     ▼
┌─────────────────────────┐
│ POST /identities/register │
├─────────────────────────┤
│ face_ids: ["face_100"]  │
│ name: "Audrey Hepburn"  │
│ source: "manual"        │
│ auto_bind_chunks: true  │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 1. Create Identity      │
│    - identity_uuid      │
│    - name, source       │
│    - face_embedding     │ (from first face)
│    - reference_data     │ (selected vectors)
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 2. Bind Faces           │
│    - Update face_detections │
│    - Set identity_id    │
│    - Update file_identities │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 3. Auto Bind Chunks     │
│    - Time alignment     │
│    - Update chunk.metadata │
│    - Update file_identities.speaker_count │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 4. Select Reference Vectors │
│    - Trace-based selection │
│    - Pose diversity     │
│    - Quality threshold  │
└─────────────────────────┘
```

**Implementation**:

```rust
pub async fn register_identity(
    db: &PgPool,
    req: RegisterIdentityRequest,
) -> Result<Identity> {
    let mut tx = db.begin().await?;
    
    // 1. Get faces
    let faces = sqlx::query_as!(
        FaceDetection,
        "SELECT * FROM face_detections WHERE id = ANY($1)",
        &req.face_ids
    )
    .fetch_all(&mut *tx)
    .await?;
    
    // 2. Create identity
    let identity = sqlx::query_as!(
        Identity,
        r#"
        INSERT INTO identities (uuid, name, source, face_embedding, reference_data)
        VALUES ($1, $2, $3, $4, $5)
        RETURNING *
        "#,
        Uuid::new_v4().to_string(),
        req.name,
        req.source,
        faces[0].embedding.clone(),
        json!({
            "vectors": vec![ReferenceVector {
                embedding: faces[0].embedding.clone(),
                pose_angle: faces[0].pose_angle.clone(),
                quality: faces[0].confidence,
                file_uuid: faces[0].file_uuid.clone(),
                face_id: faces[0].id,
            }],
            "selection_strategy": "manual"
        }),
    )
    .fetch_one(&mut *tx)
    .await?;
    
    // 3. Bind faces
    for face in &faces {
        sqlx::query!(
            "UPDATE face_detections SET identity_id = $1 WHERE id = $2",
            identity.id,
            face.id
        )
        .execute(&mut *tx)
        .await?;
        
        // Update file_identities
        update_file_identity_stats(
            &mut tx,
            &face.file_uuid,
            identity.id,
            1, // face_count +1
            0, // speaker_count
            Some(face.confidence),
            Some(face.timestamp),
        ).await?;
    }
    
    // 4. Auto bind chunks
    if req.auto_bind_chunks {
        auto_bind_chunks_for_identity(&mut tx, &identity.id, &faces).await?;
    }
    
    tx.commit().await?;
    Ok(identity)
}
```

---

### 2. Bind Faces to Existing Identity

**Trigger**: User selects face(s) and assigns to existing identity

```
User Selection
     │
     ▼
┌────────────────────────────┐
│ POST /identities/:uuid/bind │
├────────────────────────────┤
│ face_ids: ["face_200"]      │
│ auto_bind_chunks: true     │
└────────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 1. Validate Identity    │
│    - Check existence    │
│    - Get reference_data │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 2. Bind Faces           │
│    - Update face_detections │
│    - Set identity_id    │
│    - Update file_identities │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 3. Update Reference Vectors │
│    - Add new vector if quality > threshold │
│    - Maintain diversity │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 4. Auto Bind Chunks     │
│    - Time alignment     │
└─────────────────────────┘
```

**Implementation**:

```rust
pub async fn bind_faces_to_identity(
    db: &PgPool,
    identity_uuid: &str,
    req: BindFacesRequest,
) -> Result<()> {
    let mut tx = db.begin().await?;
    
    // 1. Get identity
    let identity = sqlx::query_as!(
        Identity,
        "SELECT * FROM identities WHERE uuid = $1",
        identity_uuid
    )
    .fetch_one(&mut *tx)
    .await?;
    
    // 2. Get faces
    let faces = sqlx::query_as!(
        FaceDetection,
        "SELECT * FROM face_detections WHERE id = ANY($1)",
        &req.face_ids
    )
    .fetch_all(&mut *tx)
    .await?;
    
    // 3. Bind faces
    for face in &faces {
        sqlx::query!(
            "UPDATE face_detections SET identity_id = $1 WHERE id = $2",
            identity.id,
            face.id
        )
        .execute(&mut *tx)
        .await?;
        
        update_file_identity_stats(
            &mut tx,
            &face.file_uuid,
            identity.id,
            1,
            0,
            Some(face.confidence),
            Some(face.timestamp),
        ).await?;
    }
    
    // 4. Update reference vectors
    update_reference_vectors(&mut tx, &identity.id, &faces).await?;
    
    // 5. Auto bind chunks
    if req.auto_bind_chunks {
        auto_bind_chunks_for_identity(&mut tx, &identity.id, &faces).await?;
    }
    
    tx.commit().await?;
    Ok(())
}
```

---

### 3. Unbind Faces from Identity

**Trigger**: User removes face from identity

```
User Selection
     │
     ▼
┌──────────────────────────────┐
│ POST /identities/:uuid/unbind │
├──────────────────────────────┤
│ face_ids: ["face_400"]       │
└──────────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 1. Unbind Faces         │
│    - Set identity_id = NULL │
│    - Update file_identities │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 2. Auto Unbind Chunks   │
│    - Remove if no overlapping faces │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 3. Update Reference Vectors │
│    - Remove if vector source │
│    - Re-select if needed │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ 4. Check Identity Deletion │
│    - If face_count = 0, delete identity │
└─────────────────────────┘
```

---

### 4. Auto Chunk Binding

**Trigger**: Face binding/unbinding

**Principle**: Chunk 自動綁定，無需 Candidates/Suggest API

```
Face Timestamps
     │
     ▼
┌─────────────────────────┐
│ Query Chunks by Time    │
│ - chunk.start_time <= face.timestamp │
│ - chunk.end_time >= face.timestamp │
│ - Same file_uuid        │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ Check Overlap           │
│ - Count overlapping faces │
│ - Calculate confidence │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ Update Chunk Metadata   │
│ - identity_id: ...      │
│ - confidence: 0.85      │
│ - binding_source: "auto"│
│ - faces: ["face_100"]   │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ Update file_identities  │
│ - speaker_count += 1    │
└─────────────────────────┘
```

**Implementation**:

```rust
pub async fn auto_bind_chunks_for_identity(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    identity_id: &i64,
    faces: &[FaceDetection],
) -> Result<()> {
    for face in faces {
        // Find overlapping chunks
        let chunks = sqlx::query!(
            r#"
            SELECT id, metadata
            FROM chunks
            WHERE file_uuid = $1
            AND start_time <= $2
            AND end_time >= $2
            "#,
            face.file_uuid,
            face.timestamp
        )
        .fetch_all(&mut **tx)
        .await?;
        
        for chunk in chunks {
            let mut metadata: ChunkMetadata = 
                serde_json::from_value(chunk.metadata.clone()).unwrap_or_default();
            
            // Update metadata
            if !metadata.faces.contains(&face.id) {
                metadata.faces.push(face.id);
            }
            metadata.identity_id = Some(*identity_id);
            metadata.confidence = Some(face.confidence);
            metadata.binding_source = "auto".to_string();
            
            sqlx::query!(
                r#"
                UPDATE chunks 
                SET metadata = $1
                WHERE id = $2
                "#,
                serde_json::to_value(metadata)?,
                chunk.id
            )
            .execute(&mut **tx)
            .await?;
            
            // Update file_identities speaker_count
            sqlx::query!(
                r#"
                UPDATE file_identities
                SET speaker_count = speaker_count + 1
                WHERE file_uuid = $1 AND identity_id = $2
                "#,
                face.file_uuid,
                identity_id
            )
            .execute(&mut **tx)
            .await?;
        }
    }
    
    Ok(())
}
```

---

### 5. Reference Vector Selection

**Strategy**: Trace-based + Pose diversity

```
Face Detections (identity_id = X)
     │
     ▼
┌─────────────────────────┐
│ Group by trace_id       │
│ - Each trace = one person track │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ For each trace:         │
│ - Find best frontal face │
│ - Find best profile faces │
│ - Quality > 0.85        │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ Select Top N Vectors    │
│ - Max 5 per trace       │
│ - Max 20 total          │
│ - Prioritize quality    │
└─────────────────────────┘
     │
     ▼
┌─────────────────────────┐
│ Store in reference_data │
│ {
│   "vectors": [...],
│   "selection_strategy": "trace_based",
│   "total_traces": 4,
│   "total_faces": 500
│ }
└─────────────────────────┘
```

**Implementation**:

```rust
pub async fn update_reference_vectors(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    identity_id: &i64,
    new_faces: &[FaceDetection],
) -> Result<()> {
    // Get all faces for this identity
    let all_faces = sqlx::query_as!(
        FaceDetection,
        "SELECT * FROM face_detections WHERE identity_id = $1",
        identity_id
    )
    .fetch_all(&mut **tx)
    .await?;
    
    // Group by trace_id
    let mut trace_groups: HashMap<i32, Vec<&FaceDetection>> = HashMap::new();
    for face in &all_faces {
        trace_groups.entry(face.trace_id).or_default().push(face);
    }
    
    // Select vectors per trace
    let mut selected_vectors = Vec::new();
    
    for (_trace_id, faces) in trace_groups.iter() {
        // Group by pose_angle
        let mut pose_groups: HashMap<String, Vec<&FaceDetection>> = HashMap::new();
        for face in faces {
            pose_groups
                .entry(face.pose_angle.clone())
                .or_default()
                .push(face);
        }
        
        // Select best from each pose (max 5 per trace)
        for (_, pose_faces) in pose_groups.iter() {
            let best = pose_faces
                .iter()
                .filter(|f| f.confidence > 0.85)
                .max_by(|a, b| a.confidence.partial_cmp(&b.confidence).unwrap());
            
            if let Some(face) = best {
                selected_vectors.push(ReferenceVector {
                    embedding: face.embedding.clone(),
                    pose_angle: face.pose_angle.clone(),
                    quality: face.confidence,
                    file_uuid: face.file_uuid.clone(),
                    face_id: face.id,
                });
            }
        }
    }
    
    // Sort by quality and take top 20
    selected_vectors.sort_by(|a, b| b.quality.partial_cmp(&a.quality).unwrap());
    selected_vectors.truncate(20);
    
    // Update identity
    sqlx::query!(
        r#"
        UPDATE identities
        SET reference_data = $1
        WHERE id = $2
        "#,
        json!({
            "vectors": selected_vectors,
            "selection_strategy": "trace_based",
            "total_traces": trace_groups.len(),
            "total_faces": all_faces.len(),
        }),
        identity_id
    )
    .execute(&mut **tx)
    .await?;
    
    Ok(())
}
```

---

## Query Workflows

### 1. List Identities in File

```bash
GET /api/v1/files/384b0ff44aaaa1f14cb2cd63b3fea966/identities
```

**SQL**:

```sql
SELECT 
    i.uuid AS identity_uuid,
    i.name,
    i.source,
    fi.face_count,
    fi.speaker_count,
    fi.confidence
FROM file_identities fi
JOIN identities i ON i.id = fi.identity_id
WHERE fi.file_uuid = '384b0ff44aaaa1f14cb2cd63b3fea966'
ORDER BY fi.face_count DESC;
```

---

### 2. List Files for Identity

```bash
GET /api/v1/identities/a9a90105.../files
```

**SQL**:

```sql
SELECT 
    f.uuid AS file_uuid,
    f.file_name,
    f.duration,
    fi.face_count,
    fi.speaker_count,
    fi.first_appearance,
    fi.last_appearance,
    fi.confidence
FROM file_identities fi
JOIN files f ON f.uuid = fi.file_uuid
WHERE fi.identity_id = 1
ORDER BY fi.face_count DESC;
```

---

### 3. List Faces for Identity

```bash
GET /api/v1/identities/a9a90105.../faces?limit=100
```

**SQL**:

```sql
SELECT 
    fd.id AS face_id,
    fd.file_uuid,
    fd.frame,
    fd.timestamp,
    fd.pose_angle,
    fd.confidence,
    fd.trace_id
FROM face_detections fd
WHERE fd.identity_id = 1
ORDER BY fd.timestamp
LIMIT 100;
```

---

### 4. List Unregistered Faces (Candidates)

```bash
GET /api/v1/faces/candidates?min_confidence=0.8&pose_angle=frontal
```

**SQL**:

```sql
SELECT 
    fd.id AS face_id,
    fd.file_uuid,
    fd.frame,
    fd.timestamp,
    fd.pose_angle,
    fd.confidence,
    fd.trace_id
FROM face_detections fd
WHERE fd.identity_id IS NULL
AND fd.confidence >= 0.8
AND fd.pose_angle = 'frontal'
ORDER BY fd.confidence DESC
LIMIT 100;
```

---

## Performance Considerations

### Indexing Strategy

```sql
-- Face queries
CREATE INDEX idx_face_detections_identity ON face_detections(identity_id) 
    WHERE identity_id IS NOT NULL;
CREATE INDEX idx_face_detections_candidates ON face_detections(confidence DESC) 
    WHERE identity_id IS NULL;

-- File identity queries
CREATE INDEX idx_file_identities_file_uuid ON file_identities(file_uuid);
CREATE INDEX idx_file_identities_identity_id ON file_identities(identity_id);

-- Chunk queries
CREATE INDEX idx_chunks_file_time ON chunks(file_uuid, start_time, end_time);
```

### Batch Operations

```rust
// Batch bind faces (recommended for >10 faces)
pub async fn batch_bind_faces(
    db: &PgPool,
    identity_id: i64,
    face_ids: &[i64],
) -> Result<()> {
    let mut tx = db.begin().await?;
    
    // Single UPDATE statement
    sqlx::query!(
        "UPDATE face_detections SET identity_id = $1 WHERE id = ANY($2)",
        identity_id,
        face_ids
    )
    .execute(&mut *tx)
    .await?;
    
    // Batch update file_identities
    // ... (use CTE or temp table)
    
    tx.commit().await?;
    Ok(())
}
```

---

## Error Handling

### Common Errors

| Error | Cause | Solution |
|-------|-------|----------|
| `Identity not found` | Invalid identity_uuid | Check UUID format |
| `Face already bound` | Face has identity_id | Unbind first |
| `Invalid face_ids` | Empty array or invalid IDs | Validate input |
| `Chunk overlap conflict` | Multiple identities in same chunk | Use latest binding |

---

## Version History

| Version | Date | Changes |
|---------|------|---------|
| V4.0 | 2026-04-28 | Two-layer architecture, direct binding |

---

## Related Documents

- [IDENTITY_MANAGEMENT_API.md](./IDENTITY_MANAGEMENT_API.md): API design
- [FILE_IDENTITIES_TABLE_SPEC.md](./FILE_IDENTITIES_TABLE_SPEC.md): Table schema
- [IDENTITY_AGENT_SPEC.md](./IDENTITY_AGENT_SPEC.md): Agent specification
