use super::graph::{TranscriptHistoryState, TranscriptRevisionBody, TranscriptRewriteCommit};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex, OnceLock};
pub(super) const TRANSCRIPT_GRAPH_FACT_HEAL_PROBE_CURRENT: u8 = 1;
pub(super) const TRANSCRIPT_GRAPH_FACT_VALIDATED: u8 = 2;
pub(super) fn transcript_graph_shape_key(
fact: u8,
digest_format: u32,
head: &str,
commits: &[TranscriptRewriteCommit],
revisions: &[TranscriptRevisionBody],
) -> Option<String> {
let mut hasher = Sha256::new();
hasher.update([fact]);
hasher.update((digest_format as u64).to_le_bytes());
hasher.update((head.len() as u64).to_le_bytes());
hasher.update(head.as_bytes());
hasher.update((revisions.len() as u64).to_le_bytes());
for body in revisions {
hasher.update((body.revision.len() as u64).to_le_bytes());
hasher.update(body.revision.as_bytes());
match body.parent_revision.as_deref() {
Some(parent) => {
hasher.update([1]);
hasher.update((parent.len() as u64).to_le_bytes());
hasher.update(parent.as_bytes());
}
None => hasher.update([0]),
}
let created_at = serde_json::to_vec(&body.created_at).ok()?;
hasher.update((created_at.len() as u64).to_le_bytes());
hasher.update(&created_at);
hasher.update((body.messages.len() as u64).to_le_bytes());
for message in &body.messages {
hash_digest_erased_message_fields(&mut hasher, message)?;
}
}
hasher.update((commits.len() as u64).to_le_bytes());
for commit in commits {
let bytes = serde_json::to_vec(commit).ok()?;
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
}
let digest = hasher.finalize();
let mut out = String::with_capacity(2 + digest.len() * 2);
out.push(char::from(b'0' + fact));
out.push(':');
const HEX: &[u8; 16] = b"0123456789abcdef";
for byte in digest {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
Some(out)
}
fn hash_digest_erased_message_fields(hasher: &mut Sha256, message: &crate::Message) -> Option<()> {
use crate::Message;
fn hash_time(hasher: &mut Sha256, at: &crate::types::MessageTimestamp) -> Option<()> {
let bytes = serde_json::to_vec(at).ok()?;
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
Some(())
}
fn hash_identity(
hasher: &mut Sha256,
identity: &crate::types::TranscriptMessageIdentity,
) -> Option<()> {
let bytes = serde_json::to_vec(identity).ok()?;
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
Some(())
}
fn hash_image_forms(hasher: &mut Sha256, blocks: &[crate::types::ContentBlock]) {
for block in blocks {
if let crate::types::ContentBlock::Image { data, .. } = block {
match data {
crate::types::ImageData::Inline { .. } => hasher.update([b'i']),
crate::types::ImageData::Blob { .. } => hasher.update([b'b']),
}
}
}
}
match message {
Message::System(system) => {
hasher.update([1]);
hash_time(hasher, &system.created_at)?;
}
Message::SystemNotice(notice) => {
hasher.update([2]);
hash_time(hasher, ¬ice.created_at)?;
for block in ¬ice.blocks {
match block {
crate::types::SystemNoticeBlock::Comms { content, .. }
| crate::types::SystemNoticeBlock::ExternalEvent { content, .. } => {
hash_image_forms(hasher, content);
}
_ => {}
}
}
}
Message::User(user) => {
hasher.update([3]);
hash_identity(hasher, &user.identity)?;
hash_time(hasher, &user.created_at)?;
hash_image_forms(hasher, &user.content);
}
Message::BlockAssistant(assistant) => {
hasher.update([4]);
hash_identity(hasher, &assistant.identity)?;
hash_time(hasher, &assistant.created_at)?;
}
Message::ToolResults {
created_at,
results,
..
} => {
hasher.update([5]);
hash_time(hasher, created_at)?;
for result in results {
hash_image_forms(hasher, &result.content);
}
}
}
Some(())
}
struct BoundedTranscriptGraphMemo<V> {
capacity: usize,
entries: HashMap<String, V>,
order: VecDeque<String>,
}
impl<V> BoundedTranscriptGraphMemo<V> {
fn new(capacity: usize) -> Self {
Self {
capacity,
entries: HashMap::new(),
order: VecDeque::new(),
}
}
fn get(&self, key: &str) -> Option<&V> {
self.entries.get(key)
}
fn record(&mut self, key: String, value: V) {
if self.entries.contains_key(&key) {
return;
}
while self.entries.len() >= self.capacity {
let Some(evicted) = self.order.pop_front() else {
break;
};
self.entries.remove(&evicted);
}
self.order.push_back(key.clone());
self.entries.insert(key, value);
}
}
const TRANSCRIPT_GRAPH_HEAL_PROBE_MEMO_CAPACITY: usize = 4096;
static TRANSCRIPT_GRAPH_HEAL_PROBE_MEMO: OnceLock<Mutex<BoundedTranscriptGraphMemo<()>>> =
OnceLock::new();
fn transcript_graph_heal_probe_memo() -> &'static Mutex<BoundedTranscriptGraphMemo<()>> {
TRANSCRIPT_GRAPH_HEAL_PROBE_MEMO.get_or_init(|| {
Mutex::new(BoundedTranscriptGraphMemo::new(
TRANSCRIPT_GRAPH_HEAL_PROBE_MEMO_CAPACITY,
))
})
}
const TRANSCRIPT_GRAPH_VALIDATED_MEMO_BYTE_BUDGET: usize = 256 * 1024 * 1024;
const TRANSCRIPT_GRAPH_VALIDATED_MEMO_ENTRY_CAP: usize =
TRANSCRIPT_GRAPH_VALIDATED_MEMO_BYTE_BUDGET / 2;
struct ByteBudgetedTranscriptGraphMemo {
budget_bytes: usize,
entry_cap_bytes: usize,
retained_bytes: usize,
entries: HashMap<String, (Arc<TranscriptHistoryState>, usize)>,
order: VecDeque<String>,
}
impl ByteBudgetedTranscriptGraphMemo {
fn new(budget_bytes: usize, entry_cap_bytes: usize) -> Self {
Self {
budget_bytes,
entry_cap_bytes,
retained_bytes: 0,
entries: HashMap::new(),
order: VecDeque::new(),
}
}
fn get(&mut self, key: &str) -> Option<Arc<TranscriptHistoryState>> {
let (state, _) = self.entries.get(key)?;
let state = Arc::clone(state);
if let Some(position) = self.order.iter().position(|entry| entry == key) {
let entry = self.order.remove(position);
if let Some(entry) = entry {
self.order.push_back(entry);
}
}
Some(state)
}
fn record(&mut self, key: String, value: Arc<TranscriptHistoryState>, bytes: usize) {
if bytes > self.entry_cap_bytes || self.entries.contains_key(&key) {
return;
}
while self.retained_bytes + bytes > self.budget_bytes {
let Some(evicted) = self.order.pop_front() else {
break;
};
if let Some((_, evicted_bytes)) = self.entries.remove(&evicted) {
self.retained_bytes = self.retained_bytes.saturating_sub(evicted_bytes);
}
}
self.order.push_back(key.clone());
self.retained_bytes += bytes;
self.entries.insert(key, (value, bytes));
}
}
static TRANSCRIPT_GRAPH_VALIDATED_MEMO: OnceLock<Mutex<ByteBudgetedTranscriptGraphMemo>> =
OnceLock::new();
fn transcript_graph_validated_memo() -> &'static Mutex<ByteBudgetedTranscriptGraphMemo> {
TRANSCRIPT_GRAPH_VALIDATED_MEMO.get_or_init(|| {
Mutex::new(ByteBudgetedTranscriptGraphMemo::new(
TRANSCRIPT_GRAPH_VALIDATED_MEMO_BYTE_BUDGET,
TRANSCRIPT_GRAPH_VALIDATED_MEMO_ENTRY_CAP,
))
})
}
pub(super) fn transcript_graph_heal_probe_is_memoized(key: &str) -> bool {
if std::env::var_os("MEERKAT_DISABLE_GRAPH_DECODE_MEMO").is_some() {
return false;
}
transcript_graph_heal_probe_memo()
.lock()
.map(|memo| memo.get(key).is_some())
.unwrap_or(false)
}
pub(super) fn record_transcript_graph_heal_probe(key: String) {
if let Ok(mut memo) = transcript_graph_heal_probe_memo().lock() {
memo.record(key, ());
}
}
pub(super) fn memoized_validated_transcript_graph(
key: &str,
) -> Option<Arc<TranscriptHistoryState>> {
if std::env::var_os("MEERKAT_DISABLE_GRAPH_DECODE_MEMO").is_some() {
return None;
}
transcript_graph_validated_memo()
.lock()
.ok()
.and_then(|mut memo| memo.get(key))
}
pub(super) fn record_validated_transcript_graph(key: String, state: Arc<TranscriptHistoryState>) {
let Some(bytes) = approximate_serialized_bytes(state.as_ref()) else {
return;
};
let bytes = retained_graph_bytes(state.as_ref(), bytes);
if let Ok(mut memo) = transcript_graph_validated_memo().lock() {
memo.record(key, state, bytes);
}
}
fn retained_graph_bytes(state: &TranscriptHistoryState, durable_bytes: usize) -> usize {
let anchor_messages = state
.revisions
.first()
.map_or(0, |body| body.messages.len());
let materialized: usize = state.revisions.iter().map(|body| body.messages.len()).sum();
let additional = materialized.saturating_sub(anchor_messages);
if additional == 0 {
return durable_bytes;
}
let per_message_rate = [state.revisions.first(), state.revisions.last()]
.into_iter()
.flatten()
.filter(|body| !body.messages.is_empty())
.filter_map(|body| {
let bytes = approximate_serialized_bytes(&body.messages)?.min(durable_bytes);
Some(bytes.div_ceil(body.messages.len()))
})
.max();
per_message_rate.map_or(durable_bytes, |rate| {
durable_bytes.saturating_add(rate.saturating_mul(additional))
})
}
pub(in crate::session) fn record_producer_validated_transcript_graph(
state: Arc<TranscriptHistoryState>,
approx_bytes: usize,
) {
if std::env::var_os("MEERKAT_DISABLE_GRAPH_DECODE_MEMO").is_some() {
return;
}
let Some(key) = transcript_graph_shape_key(
TRANSCRIPT_GRAPH_FACT_VALIDATED,
state.digest_format,
&state.head,
&state.commits,
&state.revisions,
) else {
return;
};
let approx_bytes = retained_graph_bytes(state.as_ref(), approx_bytes);
if let Ok(mut memo) = transcript_graph_validated_memo().lock() {
memo.record(key, state, approx_bytes);
}
}
pub(in crate::session) fn approximate_json_bytes(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Null => 4,
serde_json::Value::Bool(_) => 5,
serde_json::Value::Number(_) => 12,
serde_json::Value::String(text) => text.len().saturating_add(8),
serde_json::Value::Array(items) => items
.iter()
.map(approximate_json_bytes)
.fold(2usize.saturating_add(items.len()), usize::saturating_add),
serde_json::Value::Object(entries) => entries
.iter()
.map(|(key, value)| {
key.len()
.saturating_add(4)
.saturating_add(approximate_json_bytes(value))
})
.fold(2usize, usize::saturating_add),
}
}
fn approximate_serialized_bytes<T: serde::Serialize>(value: &T) -> Option<usize> {
struct CountingWriter(usize);
impl std::io::Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(buf.len());
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut writer = CountingWriter(0);
serde_json::to_writer(&mut writer, value).ok()?;
Some(writer.0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TranscriptGraphValidationMode {
FullVerify,
DecodeMemoized,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::session::transcript_history::graph::TRANSCRIPT_DIGEST_FORMAT_CURRENT;
use crate::time_compat::UNIX_EPOCH;
use crate::types::{Message, UserMessage};
fn graph_with_body_message_counts(counts: &[usize]) -> TranscriptHistoryState {
TranscriptHistoryState {
head: String::new(),
commits: Vec::new(),
revisions: counts
.iter()
.enumerate()
.map(|(index, count)| TranscriptRevisionBody {
revision: format!("sha256:body-{index}"),
parent_revision: None,
messages: (0..*count)
.map(|message| {
Message::User(UserMessage::text(format!("message {message}")))
})
.collect(),
created_at: UNIX_EPOCH,
})
.collect(),
digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
replay_cursor: None,
}
}
#[test]
fn retained_bytes_scale_with_the_bodies_the_graph_materializes() {
let one = graph_with_body_message_counts(&[40]);
let eight = graph_with_body_message_counts(&[40; 8]);
assert_eq!(retained_graph_bytes(&one, 1_000), 1_000);
assert_eq!(
retained_graph_bytes(&eight, 1_000),
8_000,
"eight equal-length retained bodies cost eight bodies of memory"
);
}
#[test]
fn retained_bytes_never_undercut_the_durable_size() {
let growing = graph_with_body_message_counts(&[40, 60, 100]);
assert_eq!(retained_graph_bytes(&growing, 1_000), 5_000);
let shrinking = graph_with_body_message_counts(&[100, 1]);
let head_message_bytes = approximate_serialized_bytes(&shrinking.revisions[1].messages)
.expect("head body serializes")
.min(1_000);
assert!(head_message_bytes > 10, "head rate must beat the anchor's");
assert_eq!(
retained_graph_bytes(&shrinking, 1_000),
1_000 + head_message_bytes,
"one extra one-message body costs one message on top of the \
durable size, not a rescaled document"
);
let empty = graph_with_body_message_counts(&[]);
assert_eq!(retained_graph_bytes(&empty, 1_000), 1_000);
}
#[test]
fn production_delta_shape_is_estimated_near_truth_and_admitted() {
let pool: Vec<Message> = (0..447)
.map(|message| Message::User(UserMessage::text(format!("message {message}"))))
.collect();
let graph = TranscriptHistoryState {
head: String::new(),
commits: Vec::new(),
revisions: (1..=149)
.map(|revision| TranscriptRevisionBody {
revision: format!("sha256:body-{revision}"),
parent_revision: None,
messages: pool[..revision * 3].to_vec(),
created_at: UNIX_EPOCH,
})
.collect(),
digest_format: TRANSCRIPT_DIGEST_FORMAT_CURRENT,
replay_cursor: None,
};
let durable_bytes = approximate_serialized_bytes(&graph).expect("graph serializes");
let materialized_bytes: usize = graph
.revisions
.iter()
.map(|body| approximate_serialized_bytes(&body.messages).expect("body serializes"))
.sum();
assert!(
durable_bytes.saturating_mul(4) < materialized_bytes,
"durable {durable_bytes} is not deltas over materialized {materialized_bytes}"
);
let estimate = retained_graph_bytes(&graph, durable_bytes);
assert!(
estimate >= materialized_bytes,
"the budget must never hold more memory than it thinks: \
estimate {estimate} under materialized {materialized_bytes}"
);
assert!(
estimate <= materialized_bytes.saturating_mul(4),
"estimate {estimate} strays more than 4x over materialized \
{materialized_bytes}"
);
assert!(
estimate <= TRANSCRIPT_GRAPH_VALIDATED_MEMO_ENTRY_CAP,
"estimate {estimate} exceeds the entry cap \
{TRANSCRIPT_GRAPH_VALIDATED_MEMO_ENTRY_CAP}; the memo would \
silently price out the production shape"
);
let mut memo = ByteBudgetedTranscriptGraphMemo::new(
TRANSCRIPT_GRAPH_VALIDATED_MEMO_BYTE_BUDGET,
TRANSCRIPT_GRAPH_VALIDATED_MEMO_ENTRY_CAP,
);
memo.record("production-shape".to_string(), Arc::new(graph), estimate);
assert!(
memo.get("production-shape").is_some(),
"the validated memo must admit the production delta shape"
);
}
}