1use somatize_core::action::{ActionCache, ActionResult, BlobStore, ContentHash};
29use somatize_core::cache::{CacheKey, Origin};
30use somatize_core::effect::{Effect, EffectResult};
31use somatize_core::error::{Result, SomaError};
32use std::sync::Arc;
33
34#[derive(Debug, Clone, Copy)]
36pub struct EffectSite<'a> {
37 pub run_id: &'a str,
39 pub node_id: &'a str,
41 pub turn: usize,
43 pub index: usize,
45}
46
47#[derive(Clone)]
51pub struct EffectJournal {
52 actions: Arc<dyn ActionCache>,
53 blobs: Arc<dyn BlobStore>,
54 enabled: bool,
58}
59
60impl EffectJournal {
61 pub fn new(actions: Arc<dyn ActionCache>, blobs: Arc<dyn BlobStore>) -> Self {
64 Self {
65 actions,
66 blobs,
67 enabled: true,
68 }
69 }
70
71 pub fn disabled(actions: Arc<dyn ActionCache>, blobs: Arc<dyn BlobStore>) -> Self {
74 Self {
75 actions,
76 blobs,
77 enabled: false,
78 }
79 }
80
81 pub fn is_enabled(&self) -> bool {
83 self.enabled
84 }
85
86 pub fn with_enabled(mut self, enabled: bool) -> Self {
88 self.enabled = enabled;
89 self
90 }
91
92 pub fn key(&self, site: EffectSite<'_>, effect: &Effect) -> Result<CacheKey> {
98 let effect_key = effect.cache_key()?;
99 Ok(if effect.is_pure() {
100 CacheKey::from_parts(&[b"soma-journal-v1", b"pure", &effect_key.0])
101 } else {
102 CacheKey::from_parts(&[
103 b"soma-journal-v1",
104 b"sited",
105 site.run_id.as_bytes(),
106 site.node_id.as_bytes(),
107 &site.turn.to_le_bytes(),
108 &site.index.to_le_bytes(),
109 &effect_key.0,
110 ])
111 })
112 }
113
114 pub fn lookup(&self, site: EffectSite<'_>, effect: &Effect) -> Result<Option<EffectResult>> {
121 if !self.enabled {
122 return Ok(None);
123 }
124 let key = self.key(site, effect)?;
125 let Some(record) = self.actions.get_action(&key)? else {
126 return Ok(None);
127 };
128 let Some(hash) = record.outputs.get("effect_result") else {
129 return Ok(None);
130 };
131 let Some(bytes) = self.blobs.get_bytes(hash)? else {
132 tracing::warn!(
133 node = site.node_id,
134 turn = site.turn,
135 "journal record present but its blob is gone; performing the effect again"
136 );
137 return Ok(None);
138 };
139 let result: EffectResult = serde_json::from_slice(&bytes)
140 .map_err(|e| SomaError::Cache(format!("journal: decoding effect result: {e}")))?;
141 Ok(Some(result))
142 }
143
144 pub fn record(
146 &self,
147 site: EffectSite<'_>,
148 effect: &Effect,
149 result: &EffectResult,
150 compute_ms: u64,
151 ) -> Result<()> {
152 if !self.enabled {
153 return Ok(());
154 }
155 if matches!(result, EffectResult::Failed { .. }) {
158 return Ok(());
159 }
160
161 let bytes = serde_json::to_vec(result)
162 .map_err(|e| SomaError::Cache(format!("journal: encoding effect result: {e}")))?;
163 let hash: ContentHash = self.blobs.put_bytes(&bytes)?;
164
165 let now = chrono::Utc::now();
166 let record = ActionResult {
167 key: self.key(site, effect)?,
168 outputs: [("effect_result".to_string(), hash)].into_iter().collect(),
169 output_bytes: bytes.len() as u64,
170 compute_ms,
171 deterministic: effect.is_pure(),
175 origin: Origin::Computed {
176 node_id: site.node_id.to_string(),
177 run_id: site.run_id.to_string(),
178 },
179 created_at: now,
180 last_accessed: now,
181 };
182 self.actions.put_action(&record)
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189 use crate::cache::fs_store::FsActionStore;
190 use somatize_core::effect::{LlmRequest, LlmResponse, StopReason, Usage};
191 use somatize_core::message::Message;
192 use somatize_core::value::Value;
193
194 fn store() -> (Arc<FsActionStore>, tempfile::TempDir) {
195 let dir = tempfile::tempdir().unwrap();
196 let store = Arc::new(FsActionStore::new(dir.path()).unwrap());
197 (store, dir)
198 }
199
200 fn journal(store: Arc<FsActionStore>) -> EffectJournal {
201 EffectJournal::new(store.clone(), store)
202 }
203
204 fn site<'a>(run: &'a str, node: &'a str, turn: usize) -> EffectSite<'a> {
205 EffectSite {
206 run_id: run,
207 node_id: node,
208 turn,
209 index: 0,
210 }
211 }
212
213 fn llm(prompt: &str) -> Effect {
214 Effect::Llm(LlmRequest::new(
215 "claude-opus-5",
216 vec![Message::user(prompt)].into(),
217 ))
218 }
219
220 fn reply(text: &str) -> EffectResult {
221 EffectResult::Llm(LlmResponse {
222 message: Message::assistant(text),
223 stop_reason: StopReason::EndTurn,
224 usage: Usage::default(),
225 model: None,
226 })
227 }
228
229 #[test]
230 fn records_then_replays() {
231 let (s, _d) = store();
232 let j = journal(s);
233 let effect = llm("hello");
234
235 assert!(j.lookup(site("r1", "n", 0), &effect).unwrap().is_none());
236
237 j.record(site("r1", "n", 0), &effect, &reply("hi"), 1200)
238 .unwrap();
239
240 let got = j.lookup(site("r1", "n", 0), &effect).unwrap().unwrap();
241 match got {
242 EffectResult::Llm(r) => assert_eq!(r.message.text(), "hi"),
243 other => panic!("wrong result: {other:?}"),
244 }
245 }
246
247 #[test]
250 fn impure_effects_do_not_leak_across_runs() {
251 let (s, _d) = store();
252 let j = journal(s);
253 let effect = llm("hello");
254
255 j.record(site("r1", "n", 0), &effect, &reply("hi"), 10)
256 .unwrap();
257
258 assert!(
259 j.lookup(site("r2", "n", 0), &effect).unwrap().is_none(),
260 "a fresh run reused a recorded model answer"
261 );
262 assert!(
263 j.lookup(site("r1", "other", 0), &effect).unwrap().is_none(),
264 "another node reused a recorded model answer"
265 );
266 assert!(
267 j.lookup(site("r1", "n", 1), &effect).unwrap().is_none(),
268 "a later turn reused an earlier turn's answer"
269 );
270 }
271
272 #[test]
275 fn effects_within_a_turn_are_distinct() {
276 let (s, _d) = store();
277 let j = journal(s);
278 let effect = llm("hello");
279
280 let first = EffectSite {
281 run_id: "r",
282 node_id: "n",
283 turn: 0,
284 index: 0,
285 };
286 let second = EffectSite { index: 1, ..first };
287
288 j.record(first, &effect, &reply("one"), 1).unwrap();
289 assert!(j.lookup(second, &effect).unwrap().is_none());
290 }
291
292 #[test]
295 fn pure_effects_are_shared() {
296 let (s, _d) = store();
297 let j = journal(s);
298 let effect = Effect::Graph {
299 graph: Box::new(somatize_core::graph::Graph::new()),
300 input: Value::tensor(vec![1.0], vec![1]),
301 mode: somatize_core::effect::GraphEffectMode::Forward,
302 };
303 assert!(effect.is_pure());
304
305 j.record(
306 site("r1", "n", 0),
307 &effect,
308 &EffectResult::Graph(Value::tensor(vec![2.0], vec![1])),
309 5,
310 )
311 .unwrap();
312
313 let got = j.lookup(site("r2", "elsewhere", 7), &effect).unwrap();
314 assert!(got.is_some(), "a pure effect should be reusable anywhere");
315 }
316
317 #[test]
319 fn different_effects_at_the_same_site_differ() {
320 let (s, _d) = store();
321 let j = journal(s);
322
323 j.record(site("r", "n", 0), &llm("first"), &reply("A"), 1)
324 .unwrap();
325 assert!(
326 j.lookup(site("r", "n", 0), &llm("second"))
327 .unwrap()
328 .is_none(),
329 "two different prompts shared a journal record"
330 );
331 }
332
333 #[test]
336 fn a_disabled_journal_records_nothing() {
337 let (s, _d) = store();
338 let j = journal(s).with_enabled(false);
339 let effect = llm("something sensitive");
340
341 j.record(site("r", "n", 0), &effect, &reply("x"), 1)
342 .unwrap();
343 assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_none());
344 }
345
346 fn delete_files_under(dir: &std::path::Path) {
349 for entry in std::fs::read_dir(dir).unwrap() {
350 let path = entry.unwrap().path();
351 if path.is_dir() {
352 delete_files_under(&path);
353 } else {
354 std::fs::remove_file(&path).unwrap();
355 }
356 }
357 }
358
359 #[test]
364 fn an_evicted_blob_reads_as_absent() {
365 let (s, dir) = store();
366 let j = journal(s);
367 let effect = llm("hello");
368
369 j.record(site("r", "n", 0), &effect, &reply("hi"), 1)
370 .unwrap();
371 assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_some());
372
373 delete_files_under(&dir.path().join("cas"));
375
376 assert!(
377 j.lookup(site("r", "n", 0), &effect).unwrap().is_none(),
378 "a record without its blob must be treated as a miss"
379 );
380 }
381
382 #[test]
384 fn failures_are_not_recorded() {
385 let (s, _d) = store();
386 let j = journal(s);
387 let effect = llm("hello");
388
389 j.record(
390 site("r", "n", 0),
391 &effect,
392 &EffectResult::Failed {
393 message: "connection reset".into(),
394 },
395 1,
396 )
397 .unwrap();
398
399 assert!(j.lookup(site("r", "n", 0), &effect).unwrap().is_none());
400 }
401
402 mod keying {
408 use super::*;
409 use proptest::prelude::*;
410
411 fn pure_effect() -> Effect {
412 Effect::Graph {
413 graph: Box::new(somatize_core::graph::Graph::new()),
414 input: Value::tensor(vec![1.0], vec![1]),
415 mode: somatize_core::effect::GraphEffectMode::Forward,
416 }
417 }
418
419 fn journal() -> (EffectJournal, tempfile::TempDir) {
420 let (s, d) = store();
421 (super::journal(s), d)
422 }
423
424 proptest! {
425 #[test]
429 fn pure_and_sited_keys_never_collide(
430 run in "[a-z0-9]{0,8}",
431 node in "[a-z0-9/]{0,8}",
432 turn in 0usize..64,
433 index in 0usize..8,
434 ) {
435 let (j, _d) = journal();
436 let s = EffectSite { run_id: &run, node_id: &node, turn, index };
437 prop_assert_ne!(j.key(s, &llm("q")).unwrap(), j.key(s, &pure_effect()).unwrap());
438 }
439
440 #[test]
446 fn a_sited_key_is_exactly_its_site_and_effect(
447 a in ("[a-z]{0,4}", "[a-z]{0,4}", 0usize..4, 0usize..4),
448 b in ("[a-z]{0,4}", "[a-z]{0,4}", 0usize..4, 0usize..4),
449 ) {
450 let (j, _d) = journal();
451 let sa = EffectSite { run_id: &a.0, node_id: &a.1, turn: a.2, index: a.3 };
452 let sb = EffectSite { run_id: &b.0, node_id: &b.1, turn: b.2, index: b.3 };
453 let effect = llm("same question");
454
455 prop_assert_eq!(j.key(sa, &effect).unwrap(), j.key(sa, &effect).unwrap());
456 if a == b {
457 prop_assert_eq!(j.key(sa, &effect).unwrap(), j.key(sb, &effect).unwrap());
458 } else {
459 prop_assert_ne!(j.key(sa, &effect).unwrap(), j.key(sb, &effect).unwrap());
460 }
461 }
462
463 #[test]
465 fn a_sited_key_separates_effects(
466 run in "[a-z]{0,6}",
467 node in "[a-z]{0,6}",
468 turn in 0usize..8,
469 ) {
470 let (j, _d) = journal();
471 let s = EffectSite { run_id: &run, node_id: &node, turn, index: 0 };
472 prop_assert_ne!(j.key(s, &llm("one")).unwrap(), j.key(s, &llm("two")).unwrap());
473 }
474 }
475
476 #[test]
481 fn adjacent_site_fields_do_not_blur_together() {
482 let (j, _d) = journal();
483 let effect = llm("q");
484 let key = |run: &str, node: &str| {
485 j.key(
486 EffectSite {
487 run_id: run,
488 node_id: node,
489 turn: 0,
490 index: 0,
491 },
492 &effect,
493 )
494 .unwrap()
495 };
496 assert_ne!(
497 key("ab", "c"),
498 key("a", "bc"),
499 "site fields concatenated without length prefixes"
500 );
501 }
502 }
503}