From bcb4e30ec2945028e420db7c55c30df24194afb8 Mon Sep 17 00:00:00 2001 From: Story Crater Bot <19826264+Riotpiaole@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:05:45 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20M3.8.2=20cache=20aligner=20integration?= =?UTF-8?q?=20=E2=80=94=20metrics=20+=20headers=20(3=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheMetrics struct (40 LOC): - stable_prefix_bytes, dynamic_tail_bytes - drift_metric (0.0-1.0 ratio) - cache_eligible flag (drift < 0.3) - compression_ratio() and header_* methods PromptBuilder::cache_metrics() (40 LOC): - Calculates cache alignment metrics for query+chunk pairs - Integrates CacheAligner output - Gets compression ratio from ContextOptimizer - Used for HTTP headers and observability HTTP headers ready for client integration: - X-Cache-Stable-Bytes - X-Cache-Drift - X-Cache-Eligible - X-Compression-Ratio 3 new tests: - test_cache_metrics_stable_query - test_cache_metrics_compression_ratio - test_cache_metrics_header_drift Total: 117 mem-core tests (114 before + 3 new) --- crates/mem-core/src/lib.rs | 2 +- crates/mem-core/src/prompt.rs | 131 +++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) 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"); + } }