# File Identities Table Specification

> Version: V4.0 | Date: 2026-04-28
> Architecture: Two-layer (Face → Identity)
> Relationship: N:N (Identity ↔ File)

---

## Overview

`file_identities` 表實現 Identity 與 File 的多對多關係，支援跨檔案身份追蹤。

### Key Features

| Feature | Description |
|---------|-------------|
| **N:N Relationship** | Identity 可跨多個 File，File 可包含多個 Identity |
| **Aggregate Stats** | 統計每個 File 中每個 Identity 的出現次數 |
| **Time Range** | 記錄首次/最後出現時間 |
| **Confidence** | 平均信心度 |

---

## Table Schema

```sql
CREATE TABLE file_identities (
    id BIGSERIAL PRIMARY KEY,
    file_uuid VARCHAR(64) NOT NULL,
    identity_id BIGINT NOT NULL,
    face_count INTEGER DEFAULT 0,
    speaker_count INTEGER DEFAULT 0,
    first_appearance DOUBLE PRECISION,
    last_appearance DOUBLE PRECISION,
    confidence DOUBLE PRECISION DEFAULT 0.0,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    
    CONSTRAINT fk_file_identities_file 
        FOREIGN KEY (file_uuid) 
        REFERENCES files(uuid) 
        ON DELETE CASCADE,
    
    CONSTRAINT fk_file_identities_identity 
        FOREIGN KEY (identity_id) 
        REFERENCES identities(id) 
        ON DELETE CASCADE,
    
    CONSTRAINT uq_file_identities 
        UNIQUE (file_uuid, identity_id)
);

CREATE INDEX idx_file_identities_file_uuid ON file_identities(file_uuid);
CREATE INDEX idx_file_identities_identity_id ON file_identities(identity_id);
CREATE INDEX idx_file_identities_confidence ON file_identities(confidence DESC);
```

---

## Column Descriptions

| Column | Type | Description | Example |
|--------|------|-------------|---------|
| `id` | BIGSERIAL | Primary key | `1` |
| `file_uuid` | VARCHAR(64) | File identifier (FK to files.uuid) | `384b0ff44aaaa1f14cb2cd63b3fea966` |
| `identity_id` | BIGINT | Identity ID (FK to identities.id) | `1` |
| `face_count` | INTEGER | Number of faces bound to identity in this file | `500` |
| `speaker_count` | INTEGER | Number of speaker segments bound | `10` |
| `first_appearance` | DOUBLE PRECISION | First appearance time in seconds | `5.2` |
| `last_appearance` | DOUBLE PRECISION | Last appearance time in seconds | `180.5` |
| `confidence` | DOUBLE PRECISION | Average confidence score | `0.86` |
| `created_at` | TIMESTAMPTZ | Record creation time | `2026-04-28T10:00:00Z` |
| `updated_at` | TIMESTAMPTZ | Record update time | `2026-04-28T12:00:00Z` |

---

## Relationships

### Identity → Files (One-to-Many)

```
identities (1) ──→ file_identities (N) ──→ files (N)
```

**Query**: List all files where an identity appears

```sql
SELECT 
    f.uuid AS file_uuid,
    f.file_name,
    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 = ?
ORDER BY fi.face_count DESC;
```

### File → Identities (One-to-Many)

```
files (1) ──→ file_identities (N) ──→ identities (N)
```

**Query**: List all identities in a file

```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 = ?
ORDER BY fi.face_count DESC;
```

---

## Data Flow

### 1. Face Binding

When a face is bound to an identity:

```sql
-- Step 1: Create file_identities record if not exists
INSERT INTO file_identities (file_uuid, identity_id, face_count, confidence)
VALUES (?, ?, 1, ?)
ON CONFLICT (file_uuid, identity_id) 
DO UPDATE SET 
    face_count = file_identities.face_count + 1,
    confidence = (file_identities.confidence * file_identities.face_count + EXCLUDED.confidence) / (file_identities.face_count + 1),
    updated_at = NOW();

-- Step 2: Update first/last appearance
UPDATE file_identities 
SET 
    first_appearance = LEAST(first_appearance, ?),
    last_appearance = GREATEST(last_appearance, ?)
WHERE file_uuid = ? AND identity_id = ?;
```

### 2. Face Unbinding

When a face is unbound from an identity:

```sql
-- Step 1: Get face info before unbinding
SELECT file_uuid, confidence FROM face_detections WHERE id = ?;

-- Step 2: Update file_identities
UPDATE file_identities 
SET 
    face_count = face_count - 1,
    updated_at = NOW()
WHERE file_uuid = ? AND identity_id = ?;

-- Step 3: Delete if face_count = 0
DELETE FROM file_identities 
WHERE file_uuid = ? AND identity_id = ? AND face_count = 0;
```

### 3. Chunk Binding (Auto)

When a chunk is auto-bound to an identity via time alignment:

```sql
-- Update speaker_count
UPDATE file_identities 
SET 
    speaker_count = speaker_count + 1,
    updated_at = NOW()
WHERE file_uuid = ? AND identity_id = ?;
```

---

## Indexes

| Index | Purpose |
|-------|---------|
| `idx_file_identities_file_uuid` | Query identities by file |
| `idx_file_identities_identity_id` | Query files by identity |
| `idx_file_identities_confidence` | Sort by confidence |

---

## Constraints

### Foreign Keys

| Constraint | On Delete | Description |
|------------|-----------|-------------|
| `fk_file_identities_file` | CASCADE | Delete file_identities when file is deleted |
| `fk_file_identities_identity` | CASCADE | Delete file_identities when identity is deleted |

### Unique Constraint

```sql
CONSTRAINT uq_file_identities UNIQUE (file_uuid, identity_id)
```

Ensures one record per file-identity pair.

---

## Query Patterns

### 1. Get Identity Files

```rust
pub async fn get_identity_files(
    db: &PgPool,
    identity_uuid: &str,
    page: i64,
    page_size: i64,
) -> Result<IdentityFilesResponse> {
    let rows = sqlx::query_as!(
        FileIdentityRow,
        r#"
        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
        JOIN identities i ON i.id = fi.identity_id
        WHERE i.uuid = $1
        ORDER BY fi.face_count DESC
        LIMIT $2 OFFSET $3
        "#,
        identity_uuid,
        page_size,
        (page - 1) * page_size
    )
    .fetch_all(db)
    .await?;
    
    Ok(IdentityFilesResponse { files: rows })
}
```

### 2. Get File Identities

```rust
pub async fn get_file_identities(
    db: &PgPool,
    file_uuid: &str,
    page: i64,
    page_size: i64,
) -> Result<FileIdentitiesResponse> {
    let rows = sqlx::query_as!(
        IdentityRow,
        r#"
        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 = $1
        ORDER BY fi.face_count DESC
        LIMIT $2 OFFSET $3
        "#,
        file_uuid,
        page_size,
        (page - 1) * page_size
    )
    .fetch_all(db)
    .await?;
    
    Ok(FileIdentitiesResponse { identities: rows })
}
```

### 3. Update Stats

```rust
pub async fn update_file_identity_stats(
    db: &PgPool,
    file_uuid: &str,
    identity_id: i64,
    face_count_delta: i32,
    speaker_count_delta: i32,
    confidence: Option<f64>,
    timestamp: Option<f64>,
) -> Result<()> {
    sqlx::query!(
        r#"
        INSERT INTO file_identities (file_uuid, identity_id, face_count, speaker_count, confidence, first_appearance, last_appearance)
        VALUES ($1, $2, $3, $4, $5, $6, $6)
        ON CONFLICT (file_uuid, identity_id) 
        DO UPDATE SET 
            face_count = file_identities.face_count + $3,
            speaker_count = file_identities.speaker_count + $4,
            confidence = CASE 
                WHEN $5 IS NOT NULL AND file_identities.face_count > 0 
                THEN (file_identities.confidence * file_identities.face_count + $5) / (file_identities.face_count + $3)
                ELSE file_identities.confidence 
            END,
            first_appearance = CASE 
                WHEN $6 IS NOT NULL 
                THEN LEAST(file_identities.first_appearance, $6) 
                ELSE file_identities.first_appearance 
            END,
            last_appearance = CASE 
                WHEN $6 IS NOT NULL 
                THEN GREATEST(file_identities.last_appearance, $6) 
                ELSE file_identities.last_appearance 
            END,
            updated_at = NOW()
        "#,
        file_uuid,
        identity_id,
        face_count_delta,
        speaker_count_delta,
        confidence,
        timestamp
    )
    .execute(db)
    .await?;
    
    Ok(())
}
```

---

## Migration

### V3.x → V4.0

**Before (V3.x)**:
- `person_identities` table (303 records, 0 registered identities)
- One-to-many relationship (person → identities)
- Video-local person IDs

**After (V4.0)**:
- `file_identities` table (new)
- Many-to-many relationship (identity ↔ file)
- Global identity UUIDs
- Direct face → identity binding

### Migration Script

```sql
-- Step 1: Create file_identities table
CREATE TABLE file_identities ( ... );

-- Step 2: Populate from face_detections
INSERT INTO file_identities (file_uuid, identity_id, face_count, confidence, first_appearance, last_appearance)
SELECT 
    fd.file_uuid,
    fd.identity_id,
    COUNT(*) AS face_count,
    AVG(fd.confidence) AS confidence,
    MIN(fd.timestamp) AS first_appearance,
    MAX(fd.timestamp) AS last_appearance
FROM face_detections fd
WHERE fd.identity_id IS NOT NULL
GROUP BY fd.file_uuid, fd.identity_id;

-- Step 3: Update speaker_count from chunks
UPDATE file_identities fi
SET speaker_count = (
    SELECT COUNT(DISTINCT c.id)
    FROM chunks c
    WHERE c.file_uuid = fi.file_uuid
    AND c.metadata->>'identity_id' = fi.identity_id::text
);

-- Step 4: Drop person_identities table
DROP TABLE IF EXISTS person_identities;
```

---

## Performance Considerations

### Index Strategy

| Query Pattern | Index |
|---------------|-------|
| Get identities by file | `idx_file_identities_file_uuid` |
| Get files by identity | `idx_file_identities_identity_id` |
| Sort by confidence | `idx_file_identities_confidence` |

### Query Optimization

1. **Use JOINs sparingly**: Fetch identity/file data separately when possible
2. **Pagination**: Always use `LIMIT` and `OFFSET`
3. **Batch updates**: Use transactions for bulk face binding

### Caching Strategy

```rust
// Redis cache key patterns
const CACHE_KEY_FILE_IDENTITIES: &str = "momentry:file_identities:{}";
const CACHE_KEY_IDENTITY_FILES: &str = "momentry:identity_files:{}";

// Cache TTL (5 minutes)
const CACHE_TTL: i64 = 300;
```

---

## Version History

| Version | Date | Changes |
|---------|------|---------|
| V4.0 | 2026-04-28 | Initial design (N:N relationship) |

---

## Related Documents

- [IDENTITY_MANAGEMENT_API.md](./IDENTITY_MANAGEMENT_API.md): Identity API design
- [IDENTITY_AGENT_SPEC.md](./IDENTITY_AGENT_SPEC.md): Identity Agent specification
- [FACE_TO_IDENTITY_FLOW.md](./FACE_TO_IDENTITY_FLOW.md): Face binding workflow
