ai_memory/multistep_ingest/cache.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Form 3 — prompt-cache key derivation + shared-prefix builder.
5//!
6//! The Batman exemplar's OpenKB four-step pipeline shares a SYSTEM
7//! PROMPT prefix across every LLM stage so the prompt-cache hits. This
8//! module owns the prefix builder and the deterministic key derivation
9//! that lets telemetry assert "stages within a run share the cache key".
10//!
11//! # Cache key
12//!
13//! [`CacheKey`] is the SHA-256 hash of the SHARED PREFIX bytes. Two LLM
14//! calls within the same pipeline run derive the SAME key because the
15//! prefix is the same string. Two calls across different pipeline
16//! variants derive DIFFERENT keys because the prefix carries the
17//! variant tag. This is the substrate-side invariant the acceptance
18//! tests pin.
19//!
20//! # Telemetry
21//!
22//! [`PromptCacheTelemetry`] is the small recorder the executor threads
23//! through every LLM dispatch. The MCP tool surface and the test suite
24//! both inspect it to verify cache reuse without having to drive a
25//! real Ollama process.
26
27use std::sync::Mutex;
28
29use sha2::{Digest, Sha256};
30
31/// Deterministic prompt-cache key. Wraps a 64-character hex SHA-256
32/// digest of the shared-prefix bytes that an LLM stage prepended to its
33/// stage-specific prompt body.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct CacheKey(pub String);
36
37impl CacheKey {
38 /// Derive a key from raw prefix bytes. The caller is responsible
39 /// for assembling the prefix; this function just hashes it.
40 #[must_use]
41 pub fn from_prefix(prefix: &str) -> Self {
42 let mut hasher = Sha256::new();
43 hasher.update(prefix.as_bytes());
44 let digest = hasher.finalize();
45 Self(format!("{digest:x}"))
46 }
47
48 /// Hex string view (load-bearing for the telemetry JSON dump).
49 #[must_use]
50 pub fn as_hex(&self) -> &str {
51 &self.0
52 }
53}
54
55/// The Batman explicit-trust phrasing the audit pins. Threaded into
56/// every LLM stage's prompt so test assertions on the prompt string
57/// have a stable hook. The exact wording mirrors the
58/// Understand-Anything six-of-nine exemplar (`Do NOT re-run discovery
59/// commands or re-count lines, trust the script's results entirely`)
60/// shortened for the prompt context.
61pub const EXPLICIT_TRUST_INSTRUCTION: &str = "\
62Do NOT re-run discovery. The following pre-computed helper output is \
63authoritative; trust it.";
64
65/// Build the shared prefix for an LLM stage. Every stage within a
66/// pipeline run uses the SAME `pipeline_variant` + `system_prompt`
67/// inputs, which is what keeps the cache key stable. Stage-specific
68/// content goes into the body AFTER the prefix and does NOT affect
69/// cache reuse.
70///
71/// Layout:
72///
73/// ```text
74/// [SYSTEM] You are an ingest assistant for the v0.7.0 multi-step
75/// ingest substrate (variant=<variant>). <system_prompt>
76/// [TRUST INSTRUCTION] Do NOT re-run discovery. ...
77/// ```
78#[must_use]
79pub fn build_shared_prefix(pipeline_variant: &str, system_prompt: &str) -> String {
80 format!(
81 "[SYSTEM] You are an ingest assistant for the v0.7.0 multi-step ingest \
82 substrate (variant={pipeline_variant}). {system_prompt}\n\
83 [TRUST INSTRUCTION] {EXPLICIT_TRUST_INSTRUCTION}\n"
84 )
85}
86
87/// Recorder threaded through every LLM dispatch by the executor. Lets
88/// the MCP tool surface and integration tests observe whether stages
89/// within a run share the cache key.
90#[derive(Debug, Default)]
91pub struct PromptCacheTelemetry {
92 keys: Mutex<Vec<CacheKey>>,
93}
94
95impl PromptCacheTelemetry {
96 /// Construct an empty telemetry recorder.
97 #[must_use]
98 pub fn new() -> Self {
99 Self {
100 keys: Mutex::new(Vec::new()),
101 }
102 }
103
104 /// Record a cache key (called by the executor before each LLM
105 /// dispatch). A poisoned mutex is treated as "drop the record"
106 /// rather than panic — telemetry should never wedge the dispatch.
107 pub fn record(&self, key: CacheKey) {
108 if let Ok(mut g) = self.keys.lock() {
109 g.push(key);
110 }
111 }
112
113 /// Snapshot the recorded keys in observation order. Used by tests
114 /// + the MCP tool's response trace.
115 #[must_use]
116 pub fn snapshot(&self) -> Vec<CacheKey> {
117 self.keys.lock().map(|g| g.clone()).unwrap_or_default()
118 }
119
120 /// `true` if every recorded key is identical. The Form 3
121 /// acceptance criterion: stages within a run must share the cache
122 /// key. With zero or one recordings the predicate trivially holds.
123 #[must_use]
124 pub fn all_keys_match(&self) -> bool {
125 let snap = self.snapshot();
126 match snap.split_first() {
127 None => true,
128 Some((first, rest)) => rest.iter().all(|k| k == first),
129 }
130 }
131
132 /// Number of recordings.
133 #[must_use]
134 pub fn len(&self) -> usize {
135 self.snapshot().len()
136 }
137
138 /// `true` if no keys have been recorded.
139 #[must_use]
140 pub fn is_empty(&self) -> bool {
141 self.len() == 0
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn cache_key_is_deterministic_for_same_prefix() {
151 let a = CacheKey::from_prefix("hello world");
152 let b = CacheKey::from_prefix("hello world");
153 assert_eq!(a, b);
154 assert_eq!(a.as_hex().len(), 64);
155 }
156
157 #[test]
158 fn cache_key_differs_for_different_prefixes() {
159 let a = CacheKey::from_prefix("hello");
160 let b = CacheKey::from_prefix("world");
161 assert_ne!(a, b);
162 }
163
164 #[test]
165 fn shared_prefix_includes_trust_instruction_verbatim() {
166 let prefix = build_shared_prefix("two_phase", "Summarise.");
167 assert!(
168 prefix.contains(EXPLICIT_TRUST_INSTRUCTION),
169 "prefix must carry the explicit-trust instruction"
170 );
171 assert!(prefix.contains("variant=two_phase"));
172 }
173
174 #[test]
175 fn shared_prefix_differs_per_variant() {
176 let a = build_shared_prefix("two_phase", "Same.");
177 let b = build_shared_prefix("four_step", "Same.");
178 assert_ne!(a, b);
179 assert_ne!(CacheKey::from_prefix(&a), CacheKey::from_prefix(&b));
180 }
181
182 #[test]
183 fn telemetry_all_keys_match_holds_for_empty_and_single() {
184 let t = PromptCacheTelemetry::new();
185 assert!(t.all_keys_match(), "empty telemetry trivially matches");
186 t.record(CacheKey::from_prefix("a"));
187 assert!(t.all_keys_match(), "single record trivially matches");
188 }
189
190 #[test]
191 fn telemetry_detects_drift_across_records() {
192 let t = PromptCacheTelemetry::new();
193 t.record(CacheKey::from_prefix("a"));
194 t.record(CacheKey::from_prefix("b"));
195 assert!(!t.all_keys_match(), "differing keys should fail the check");
196 assert_eq!(t.len(), 2);
197 }
198
199 #[test]
200 fn telemetry_matches_when_every_record_is_identical() {
201 let t = PromptCacheTelemetry::new();
202 let key = CacheKey::from_prefix("shared");
203 t.record(key.clone());
204 t.record(key.clone());
205 t.record(key);
206 assert!(t.all_keys_match());
207 assert_eq!(t.len(), 3);
208 }
209}