Skip to main content

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}