# Caching Strategy Rules

## Core Principles
1. **Cache After Stability**: Only cache when v1 is working perfectly
2. **Multi-Layer Strategy**: Different TTLs for different data types
3. **Cost-Driven Decisions**: Cache expensive operations first
4. **Invalidation Over Staleness**: Better to miss than serve stale data

## Caching Layers (In Priority Order)

### Layer 1: AI Enhancement Cache (Highest Priority)
```python
# MOST EXPENSIVE: $0.001-0.015 per call
enhancement_cache = {
    "key": f"enhance:{content_hash}:{model}:{prompt_version}",
    "ttl": 7 * DAYS,  # Long TTL - output rarely changes
    "storage": "redis",  # Fast access
    "compression": True,  # Reduce storage costs
    "invalidate_on": ["prompt_change", "model_upgrade"]
}

# Implementation
async def get_enhanced_transcript(raw_text: str, model: str):
    cache_key = generate_cache_key(raw_text, model)
    
    # Try cache first
    cached = await redis.get(cache_key)
    if cached:
        metrics.record_cache_hit("enhancement", cost_saved=0.01)
        return decompress(cached)
    
    # Cache miss - process and store
    enhanced = await ai_enhance(raw_text, model)
    await redis.set(
        cache_key,
        compress(enhanced),
        ex=7 * 24 * 3600
    )
    return enhanced
```

### Layer 2: Transcript Cache (Second Priority)
```python
# EXPENSIVE: Processing time, not money
transcript_cache = {
    "key": f"transcript:{file_hash}:{model}:{params_hash}",
    "ttl": 30 * DAYS,  # Very stable
    "storage": "database",  # Persistent
    "compression": True,
    "invalidate_on": ["file_change", "model_change"]
}

# Database caching table
CREATE TABLE transcript_cache (
    cache_key VARCHAR(255) PRIMARY KEY,
    file_hash VARCHAR(64) NOT NULL,
    model_version VARCHAR(50),
    content JSONB NOT NULL,
    compressed BOOLEAN DEFAULT FALSE,
    hit_count INTEGER DEFAULT 0,
    last_accessed TIMESTAMP DEFAULT NOW(),
    created_at TIMESTAMP DEFAULT NOW(),
    expires_at TIMESTAMP,
    
    INDEX idx_file_hash (file_hash),
    INDEX idx_expires (expires_at)
);
```

### Layer 3: Audio Preprocessing Cache
```python
# MODERATE: FFmpeg processing time
preprocessing_cache = {
    "key": f"audio:{file_hash}:16khz_mono",
    "ttl": 7 * DAYS,
    "storage": "filesystem",  # Large files
    "path": "/tmp/trax/audio_cache/",
    "max_size": 10 * GB,
    "eviction": "LRU"
}

# Filesystem cache management
class AudioCache:
    def get_processed_audio(self, source: Path) -> Path:
        file_hash = calculate_hash(source)
        cache_path = self.cache_dir / f"{file_hash}_16khz.wav"
        
        if cache_path.exists():
            # Check if still valid
            if cache_path.stat().st_mtime > source.stat().st_mtime:
                self.touch(cache_path)  # Update access time
                return cache_path
        
        # Process and cache
        processed = preprocess_audio(source)
        shutil.copy2(processed, cache_path)
        self.enforce_size_limit()  # LRU eviction
        return cache_path
```

### Layer 4: Diarization Cache (Future)
```python
# EXPENSIVE: Complex ML processing
diarization_cache = {
    "key": f"diarization:{file_hash}:{model}",
    "ttl": 30 * DAYS,
    "storage": "database",
    "invalidate_on": ["model_change"]
}
```

## Cache Key Generation Rules

### Deterministic Keys
```python
def generate_cache_key(*args, **kwargs) -> str:
    """REQUIRED: Consistent, deterministic keys"""
    # Sort kwargs for consistency
    sorted_kwargs = sorted(kwargs.items())
    
    # Create stable hash
    hasher = hashlib.sha256()
    for arg in args:
        hasher.update(str(arg).encode())
    for key, value in sorted_kwargs:
        hasher.update(f"{key}:{value}".encode())
    
    # Return readable key
    prefix = args[0] if args else "cache"
    return f"{prefix}:{hasher.hexdigest()[:16]}"
```

### Content-Based Keys
```python
def content_hash(file_path: Path) -> str:
    """REQUIRED: Hash file content, not path"""
    hasher = hashlib.sha256()
    with open(file_path, 'rb') as f:
        for chunk in iter(lambda: f.read(8192), b''):
            hasher.update(chunk)
    return hasher.hexdigest()
```

## Cache Invalidation Rules

### Automatic Invalidation
```python
# REQUIRED: Clear related caches
async def invalidate_transcript_caches(media_file_id: UUID):
    """Invalidate all caches for a media file"""
    # Get file hash
    media_file = await get_media_file(media_file_id)
    file_hash = media_file.file_hash
    
    # Clear all related caches
    patterns = [
        f"transcript:{file_hash}:*",
        f"enhance:*:{file_hash}:*",
        f"audio:{file_hash}:*",
        f"diarization:{file_hash}:*"
    ]
    
    for pattern in patterns:
        await redis.delete_pattern(pattern)
    
    # Clear database cache
    await db.execute(
        "DELETE FROM transcript_cache WHERE file_hash = :hash",
        {"hash": file_hash}
    )
```

### Manual Invalidation
```python
# CLI commands for cache management
@click.command()
@click.option('--pattern', help='Cache key pattern')
@click.option('--older-than', type=int, help='Days old')
def clear_cache(pattern: str = None, older_than: int = None):
    """Clear cache entries"""
    if pattern:
        count = cache.delete_pattern(pattern)
        click.echo(f"Deleted {count} entries matching {pattern}")
    
    if older_than:
        cutoff = datetime.now() - timedelta(days=older_than)
        count = cache.delete_older_than(cutoff)
        click.echo(f"Deleted {count} entries older than {older_than} days")
```

## Cache Storage Rules

### Redis Configuration
```python
# REQUIRED: Redis for hot data
REDIS_CONFIG = {
    "host": "localhost",
    "port": 6379,
    "db": 0,
    "max_connections": 50,
    "decode_responses": False,  # Binary data
    "socket_keepalive": True,
    "socket_connect_timeout": 5,
    "retry_on_timeout": True
}

# Memory management
redis_client.config_set('maxmemory', '2gb')
redis_client.config_set('maxmemory-policy', 'allkeys-lru')
```

### Database Cache Table
```sql
-- REQUIRED: Periodic cleanup
CREATE OR REPLACE FUNCTION cleanup_expired_cache()
RETURNS void AS $$
BEGIN
    DELETE FROM transcript_cache 
    WHERE expires_at < NOW();
    
    -- Delete least recently used if over size limit
    DELETE FROM transcript_cache
    WHERE cache_key IN (
        SELECT cache_key 
        FROM transcript_cache
        ORDER BY last_accessed ASC
        LIMIT 1000
    )
    AND (
        SELECT SUM(pg_column_size(content)) 
        FROM transcript_cache
    ) > 1073741824; -- 1GB limit
END;
$$ LANGUAGE plpgsql;

-- Schedule hourly
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule('cleanup-cache', '0 * * * *', 
    'SELECT cleanup_expired_cache()');
```

### Filesystem Cache
```python
class FilesystemCache:
    """REQUIRED: For large binary data"""
    
    def __init__(self, cache_dir: Path, max_size: int):
        self.cache_dir = cache_dir
        self.max_size = max_size
        self.cache_dir.mkdir(parents=True, exist_ok=True)
    
    def enforce_size_limit(self):
        """LRU eviction when over size"""
        total_size = sum(
            f.stat().st_size 
            for f in self.cache_dir.glob("*")
        )
        
        if total_size > self.max_size:
            # Sort by access time
            files = sorted(
                self.cache_dir.glob("*"),
                key=lambda f: f.stat().st_atime
            )
            
            # Delete oldest until under limit
            for f in files:
                if total_size <= self.max_size * 0.8:  # 80% target
                    break
                size = f.stat().st_size
                f.unlink()
                total_size -= size
```

## Cache Warming Rules

### Predictive Warming
```python
async def warm_cache_predictively():
    """REQUIRED: Warm cache during low usage"""
    # Find frequently accessed but uncached items
    frequent_files = await db.fetch("""
        SELECT media_file_id, COUNT(*) as access_count
        FROM access_logs
        WHERE timestamp > NOW() - INTERVAL '7 days'
        GROUP BY media_file_id
        ORDER BY access_count DESC
        LIMIT 10
    """)
    
    for file in frequent_files:
        # Check if cached
        if not await is_cached(file.media_file_id):
            # Process during off-peak
            await queue_for_processing(
                file.media_file_id,
                priority=PRIORITY_BACKGROUND
            )
```

### Batch Cache Loading
```python
async def preload_batch_cache(file_paths: List[Path]):
    """Preload cache for batch processing"""
    # Calculate all hashes first
    hashes = await asyncio.gather(*[
        calculate_hash_async(path) 
        for path in file_paths
    ])
    
    # Check what's already cached
    cache_keys = [f"transcript:{h}:*" for h in hashes]
    cached = await redis.mget(cache_keys)
    
    # Return what needs processing
    return [
        path for path, is_cached 
        in zip(file_paths, cached)
        if not is_cached
    ]
```

## Monitoring Rules

### Cache Metrics
```python
cache_metrics = {
    # Performance
    "hit_rate": cache_hits / (cache_hits + cache_misses),
    "miss_rate": cache_misses / (cache_hits + cache_misses),
    "avg_response_time": {
        "hit": avg_hit_response_ms,
        "miss": avg_miss_response_ms
    },
    
    # Storage
    "memory_usage": {
        "redis": redis_memory_mb,
        "database": cache_table_size_mb,
        "filesystem": cache_dir_size_gb
    },
    
    # Business value
    "cost_saved": sum_of_saved_api_costs,
    "time_saved": sum_of_saved_processing_seconds,
    "efficiency": cost_saved / storage_cost
}
```

### Alert Thresholds
```python
cache_alerts = {
    "low_hit_rate": hit_rate < 0.7,
    "high_memory": redis_memory > 1800,  # MB
    "filesystem_full": cache_dir_size > 9,  # GB
    "stale_data": oldest_cache_entry > 60,  # days
}
```

## Testing Rules

### Cache Testing
```python
@pytest.fixture
def mock_cache():
    """REQUIRED: Test without real cache"""
    cache = {}
    
    async def get(key):
        return cache.get(key)
    
    async def set(key, value, ex=None):
        cache[key] = value
    
    async def delete(key):
        cache.pop(key, None)
    
    return SimpleNamespace(
        get=get, set=set, delete=delete
    )

async def test_cache_hit_improves_performance(mock_cache):
    # First call - cache miss
    start = time.time()
    result1 = await process_with_cache(data, cache=mock_cache)
    miss_time = time.time() - start
    
    # Second call - cache hit
    start = time.time()
    result2 = await process_with_cache(data, cache=mock_cache)
    hit_time = time.time() - start
    
    assert result1 == result2
    assert hit_time < miss_time * 0.1  # 10x faster
```

---

*These rules ensure efficient caching without premature optimization.*