diff --git a/crates/mem-core/src/lib.rs b/crates/mem-core/src/lib.rs index fe9b455..794546e 100644 --- a/crates/mem-core/src/lib.rs +++ b/crates/mem-core/src/lib.rs @@ -18,6 +18,6 @@ pub use lesson::{ tool_of_cmd, Confidence, Event, Hit, Lesson, Signature, Tier, }; pub use query::{Query, QuerySet, SynthesisQuery}; -pub use prompt::{PromptBuilder, PromptMessages}; +pub use prompt::{PromptBuilder, PromptMessages, CacheMetrics}; pub use symptom_projection::{project_symptom, SymptomVector}; pub use optimizer::{ContextOptimizer, ContextOptimizerConfig, ContentType, OptimizedChunk, CacheAligner, AlignedContent, CcrStore}; diff --git a/crates/mem-core/src/prompt.rs b/crates/mem-core/src/prompt.rs index 28fd925..0aec6ba 100644 --- a/crates/mem-core/src/prompt.rs +++ b/crates/mem-core/src/prompt.rs @@ -65,6 +65,44 @@ impl PromptMessages { } } +/// Cache alignment metrics for observability and header generation. +#[derive(Debug, Clone)] +pub struct CacheMetrics { + /// Size of stable prefix (bytes) that can be cached by LLM provider + pub stable_prefix_bytes: usize, + /// Size of dynamic tail (bytes) that varies per call + pub dynamic_tail_bytes: usize, + /// Drift metric: ratio of dynamic content (0.0 = stable, 1.0 = all dynamic) + pub drift_metric: f32, + /// Whether this chunk is cache-eligible (drift < 0.3) + pub cache_eligible: bool, + /// Estimated tokens in compressed form + pub compressed_tokens: usize, + /// Estimated tokens in original form + pub original_tokens: usize, +} + +impl CacheMetrics { + /// Compression ratio as percentage + pub fn compression_ratio(&self) -> f32 { + if self.original_tokens == 0 { + 0.0 + } else { + (self.compressed_tokens as f32 / self.original_tokens as f32) * 100.0 + } + } + + /// Generate HTTP header value for drift + pub fn header_drift(&self) -> String { + format!("{:.2}", self.drift_metric) + } + + /// Generate HTTP header value for eligible status + pub fn header_eligible(&self) -> String { + if self.cache_eligible { "true" } else { "false" }.to_string() + } +} + /// Builds GRU-Mem prompts for the update gate. /// /// Supports two modes: @@ -98,6 +136,36 @@ impl PromptMessages { pub struct PromptBuilder; impl PromptBuilder { + /// Calculate cache alignment metrics for a query and chunk. + pub fn cache_metrics(query: &Query, chunk: &Chunk) -> Result { + use crate::optimizer::{ContextOptimizer, CacheAligner}; + + let chunk_text = Self::render_chunk(chunk)?; + let aligned = CacheAligner::align(&chunk_text); + + // Get compression metrics if optimizer is available + let (original_tokens, compressed_tokens) = if let Ok(optimizer) = ContextOptimizer::from_env() { + if let Ok(optimized) = optimizer.optimize(&chunk_text) { + (optimized.original_tokens, optimized.compressed_tokens) + } else { + let tokens = estimate_tokens(&chunk_text); + (tokens, tokens) + } + } else { + let tokens = estimate_tokens(&chunk_text); + (tokens, tokens) + }; + + Ok(CacheMetrics { + stable_prefix_bytes: aligned.stable_prefix.len(), + dynamic_tail_bytes: aligned.dynamic_tail.len(), + drift_metric: aligned.drift_metric, + cache_eligible: aligned.drift_metric < 0.3, + original_tokens, + compressed_tokens, + }) + } + /// Legacy build: single user message (backward compatible). /// /// Returns `(system_prompt, user_message)` tuple. @@ -210,10 +278,15 @@ impl PromptBuilder { } } +/// Simple token estimation (1 token ~= 4 chars or 1 word) +fn estimate_tokens(text: &str) -> usize { + (text.len() / 4).max(text.split_whitespace().count()) +} + #[cfg(test)] mod tests { use super::*; - use crate::domain::{Chunk, Record, Role, Provenance}; + use crate::domain::{Chunk, Record, Role, Provenance, Level}; use time::OffsetDateTime; fn make_test_chunk(text: &str) -> Chunk { @@ -456,4 +529,60 @@ mod tests { let result = PromptBuilder::build_cache_aligned(&query, None, &big_chunk); assert!(result.is_err(), "Should reject over-budget chunk"); } + + // ── Cache metrics tests ──────────────────────────────────────── + + #[test] + fn test_cache_metrics_stable_query() { + let query = Query { + id: "q1".to_string(), + question: "What happened?".to_string(), + exit_gate: false, + }; + let chunk = make_test_chunk("Plain stable content"); + + let result = PromptBuilder::cache_metrics(&query, &chunk); + assert!(result.is_ok()); + + let metrics = result.unwrap(); + assert!(metrics.stable_prefix_bytes > 0); + assert!(metrics.drift_metric >= 0.0 && metrics.drift_metric <= 1.0); + } + + #[test] + fn test_cache_metrics_compression_ratio() { + let query = Query { + id: "q2".to_string(), + question: "Test?".to_string(), + exit_gate: false, + }; + let chunk = make_test_chunk("ERROR: failed\nINFO: debug"); + + let result = PromptBuilder::cache_metrics(&query, &chunk); + assert!(result.is_ok()); + + let metrics = result.unwrap(); + let ratio = metrics.compression_ratio(); + assert!(ratio >= 0.0 && ratio <= 100.0); + } + + #[test] + fn test_cache_metrics_header_drift() { + let query = Query { + id: "q3".to_string(), + question: "Q?".to_string(), + exit_gate: false, + }; + let chunk = make_test_chunk("content"); + + let result = PromptBuilder::cache_metrics(&query, &chunk); + assert!(result.is_ok()); + + let metrics = result.unwrap(); + let drift_header = metrics.header_drift(); + let eligible_header = metrics.header_eligible(); + + assert!(!drift_header.is_empty()); + assert!(eligible_header == "true" || eligible_header == "false"); + } } diff --git a/tasks/M3.8.2-cache-aligner-headers.md b/tasks/M3.8.2-cache-aligner-headers.md new file mode 100644 index 0000000..9b9b594 --- /dev/null +++ b/tasks/M3.8.2-cache-aligner-headers.md @@ -0,0 +1,81 @@ +# M3.8.2 — CacheAligner: HTTP Headers + Metrics + +| Field | Value | +|---|---| +| Phase | M3.8 — Context optimization | +| Size | M — 1–2 days | +| Status | ⬜ Not started | +| Depends | M3.8.1 (CacheAligner complete) | +| Blocks | M3.8.3 | + +## Goal + +Integrate CacheAligner output into HTTP response headers and observability metrics +so that: +1. LLM provider can use cache hints +2. Monitoring can track cache effectiveness +3. Debugging can identify cache misses + +## Deliverables + +### 1. PromptBuilder::cache_metrics() + +New method returning cache metadata: + +```rust +pub struct CacheMetrics { + pub stable_prefix_bytes: usize, + pub dynamic_tail_bytes: usize, + pub drift_metric: f32, // 0.0-1.0 ratio + pub cache_eligible: bool, // true if drift < 0.3 +} + +impl PromptBuilder { + pub fn cache_metrics(query: &Query, chunk: &Chunk) -> Result +} +``` + +Tests (3): +- `test_cache_metrics_stable_query` +- `test_cache_metrics_high_drift` +- `test_cache_metrics_zero_drift` + +### 2. HTTP Response Headers + +Add to PromptBuilder output: +- `X-Cache-Stable-Bytes`: size of cacheable prefix +- `X-Cache-Drift`: 0.0-1.0 ratio +- `X-Cache-Eligible`: "true"/"false" +- `X-Compression-Ratio`: original vs. compressed + +Tests (4): +- `test_headers_present_in_response` +- `test_headers_accurate_values` +- `test_headers_skipped_when_disabled` +- `test_headers_format_valid` + +### 3. Observability Hooks + +Integrate with logging: +```rust +pub fn log_cache_metrics(metrics: &CacheMetrics) { + tracing::info!( + stable_bytes = metrics.stable_prefix_bytes, + drift = metrics.drift_metric, + eligible = metrics.cache_eligible, + "cache_alignment" + ); +} +``` + +Tests (2): +- `test_metrics_logged_on_alignment` +- `test_drift_high_triggers_warning` + +## Acceptance + +- All 9 new tests passing +- Existing 114 mem-core tests still pass +- Cache metrics accurately reflect alignment +- HTTP headers present and valid +- Zero performance overhead (< 1ms additional)