use std::collections::BTreeMap;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SandboxChange {
Upsert { content_hash: String },
Remove,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SandboxOverlay {
changes: BTreeMap<String, SandboxChange>,
}
impl SandboxOverlay {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn upsert(&mut self, memory_id: &str, content_hash: &str) {
let id = memory_id.trim();
let hash = content_hash.trim();
if id.is_empty() || hash.is_empty() {
return;
}
self.changes.insert(
id.to_string(),
SandboxChange::Upsert {
content_hash: hash.to_string(),
},
);
}
pub fn remove(&mut self, memory_id: &str) {
let id = memory_id.trim();
if !id.is_empty() {
self.changes.insert(id.to_string(), SandboxChange::Remove);
}
}
#[must_use]
pub fn changes(&self) -> &BTreeMap<String, SandboxChange> {
&self.changes
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.changes.len()
}
#[must_use]
pub fn apply(&self, baseline: &BTreeMap<String, String>) -> BTreeMap<String, String> {
let mut overlaid = baseline.clone();
for (id, change) in &self.changes {
match change {
SandboxChange::Upsert { content_hash } => {
overlaid.insert(id.clone(), content_hash.clone());
}
SandboxChange::Remove => {
overlaid.remove(id);
}
}
}
overlaid
}
#[must_use]
pub fn overlay_hash(&self) -> String {
let mut canonical = String::from("ee.sandbox_overlay.v1");
for (id, change) in &self.changes {
match change {
SandboxChange::Upsert { content_hash } => {
canonical.push('\u{0}');
push_canonical_overlay_field(&mut canonical, "upsert");
push_canonical_overlay_field(&mut canonical, id);
push_canonical_overlay_field(&mut canonical, content_hash);
}
SandboxChange::Remove => {
canonical.push('\u{0}');
push_canonical_overlay_field(&mut canonical, "remove");
push_canonical_overlay_field(&mut canonical, id);
}
}
}
format!("blake3:{}", blake3::hash(canonical.as_bytes()).to_hex())
}
}
fn push_canonical_overlay_field(canonical: &mut String, value: &str) {
canonical.push_str(&value.len().to_string());
canonical.push(':');
canonical.push_str(value);
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SandboxDiffReport {
pub overlay_hash: String,
pub added: Vec<String>,
pub modified: Vec<String>,
pub removed: Vec<String>,
pub unchanged: usize,
}
#[must_use]
pub fn diff_overlay(
baseline: &BTreeMap<String, String>,
overlay: &SandboxOverlay,
) -> SandboxDiffReport {
let overlaid = overlay.apply(baseline);
let removed: Vec<String> = baseline
.keys()
.filter(|id| !overlaid.contains_key(*id))
.cloned()
.collect();
let mut added = Vec::new();
let mut modified = Vec::new();
let mut unchanged = 0_usize;
for (id, hash) in &overlaid {
match baseline.get(id) {
None => added.push(id.clone()),
Some(base_hash) if base_hash != hash => modified.push(id.clone()),
Some(_) => unchanged += 1,
}
}
SandboxDiffReport {
overlay_hash: overlay.overlay_hash(),
added,
modified,
removed,
unchanged,
}
}
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const SANDBOX_DIFF_SCHEMA_V1: &str = "ee.sandbox.diff.v1";
#[must_use]
pub fn content_hash(content: &str) -> String {
format!("blake3:{}", blake3::hash(content.as_bytes()).to_hex())
}
#[must_use]
pub fn synthetic_memory_id(content: &str) -> String {
let digest = blake3::hash(format!("sandbox:{content}").as_bytes()).to_hex();
format!("sandbox_mem_{}", &digest[..20])
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "proposal_type", rename_all = "snake_case")]
pub enum SandboxProposal {
Remember {
memory_id: String,
content: String,
content_hash: String,
level: String,
kind: String,
},
Import {
memory_id: String,
content: String,
content_hash: String,
level: String,
kind: String,
},
Retire { memory_id: String },
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct SandboxSession {
#[serde(default)]
pub proposals: Vec<SandboxProposal>,
}
impl SandboxSession {
#[must_use]
pub fn session_path(workspace: &Path, name: &str) -> PathBuf {
workspace
.join(".ee")
.join("sandbox")
.join(format!("{name}.json"))
}
#[must_use]
pub fn load(path: &Path) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_owned());
std::fs::write(path, body)
}
#[must_use]
pub fn overlay(&self) -> SandboxOverlay {
let mut overlay = SandboxOverlay::new();
for proposal in &self.proposals {
match proposal {
SandboxProposal::Remember {
memory_id,
content_hash,
..
}
| SandboxProposal::Import {
memory_id,
content_hash,
..
} => overlay.upsert(memory_id, content_hash),
SandboxProposal::Retire { memory_id } => overlay.remove(memory_id),
}
}
overlay
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxDiffSurface {
pub schema: &'static str,
pub overlay_hash: String,
pub added: Vec<String>,
pub modified: Vec<String>,
pub removed: Vec<String>,
pub unchanged: usize,
pub proposal_count: usize,
pub durable_mutation: bool,
pub sandbox_approximation: bool,
pub approximation_reason: &'static str,
}
#[must_use]
pub fn assemble_sandbox_diff(
baseline_memories: &[(String, String)],
session: &SandboxSession,
) -> SandboxDiffSurface {
let mut baseline: BTreeMap<String, String> = BTreeMap::new();
for (memory_id, content) in baseline_memories {
baseline.insert(memory_id.clone(), content_hash(content));
}
let overlay = session.overlay();
let report = diff_overlay(&baseline, &overlay);
SandboxDiffSurface {
schema: SANDBOX_DIFF_SCHEMA_V1,
overlay_hash: report.overlay_hash,
added: report.added,
modified: report.modified,
removed: report.removed,
unchanged: report.unchanged,
proposal_count: session.proposals.len(),
durable_mutation: false,
sandbox_approximation: true,
approximation_reason: "Retrieval impact is shown as a baseline-vs-overlay change set over memory content hashes, not a live temporary index; new-memory search/pack ranking is approximated, not faithfully retrieved.",
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
SandboxOverlay, SandboxProposal, SandboxSession, assemble_sandbox_diff, content_hash,
diff_overlay, synthetic_memory_id,
};
fn baseline() -> BTreeMap<String, String> {
let mut base = BTreeMap::new();
base.insert("mem_a".to_string(), "h_a".to_string());
base.insert("mem_b".to_string(), "h_b".to_string());
base
}
#[test]
fn apply_overlays_without_mutating_baseline() {
let base = baseline();
let mut overlay = SandboxOverlay::new();
overlay.upsert("mem_a", "h_a2"); overlay.upsert("mem_c", "h_c"); overlay.remove("mem_b");
let overlaid = overlay.apply(&base);
assert_eq!(overlaid.get("mem_a").map(String::as_str), Some("h_a2"));
assert_eq!(overlaid.get("mem_c").map(String::as_str), Some("h_c"));
assert!(!overlaid.contains_key("mem_b"));
assert_eq!(base.get("mem_a").map(String::as_str), Some("h_a"));
assert!(base.contains_key("mem_b"));
}
#[test]
fn diff_classifies_added_modified_removed_unchanged() {
let mut base = baseline();
base.insert("mem_keep".to_string(), "h_keep".to_string());
let mut overlay = SandboxOverlay::new();
overlay.upsert("mem_a", "h_a2");
overlay.upsert("mem_c", "h_c");
overlay.remove("mem_b");
let report = diff_overlay(&base, &overlay);
assert_eq!(report.added, vec!["mem_c".to_string()]);
assert_eq!(report.modified, vec!["mem_a".to_string()]);
assert_eq!(report.removed, vec!["mem_b".to_string()]);
assert_eq!(report.unchanged, 1); assert!(report.overlay_hash.starts_with("blake3:"));
}
#[test]
fn overlay_hash_is_order_independent_and_change_sensitive() {
let mut forward = SandboxOverlay::new();
forward.upsert("mem_a", "h_a2");
forward.remove("mem_b");
let mut reversed = SandboxOverlay::new();
reversed.remove("mem_b");
reversed.upsert("mem_a", "h_a2");
assert_eq!(
forward.overlay_hash(),
reversed.overlay_hash(),
"overlay hash is independent of change insertion order"
);
let mut different = SandboxOverlay::new();
different.upsert("mem_a", "h_a3");
different.remove("mem_b");
assert_ne!(forward.overlay_hash(), different.overlay_hash());
}
#[test]
fn overlay_hash_is_not_ambiguous_when_fields_contain_separator_bytes() {
let mut separator_in_id = SandboxOverlay::new();
separator_in_id.upsert("mem_a\u{0}hash", "tail");
let mut separator_in_hash = SandboxOverlay::new();
separator_in_hash.upsert("mem_a", "hash\u{0}tail");
assert_ne!(
separator_in_id.overlay_hash(),
separator_in_hash.overlay_hash(),
"length-prefixed fields keep distinct overlays from sharing a hash"
);
}
#[test]
fn empty_overlay_is_a_no_op() {
let base = baseline();
let overlay = SandboxOverlay::new();
assert!(overlay.is_empty());
let report = diff_overlay(&base, &overlay);
assert!(report.added.is_empty());
assert!(report.modified.is_empty());
assert!(report.removed.is_empty());
assert_eq!(report.unchanged, base.len());
}
#[test]
fn session_proposals_build_the_expected_overlay() {
let session = SandboxSession {
proposals: vec![
SandboxProposal::Remember {
memory_id: synthetic_memory_id("a brand new fact"),
content: "a brand new fact".to_owned(),
content_hash: content_hash("a brand new fact"),
level: "episodic".to_owned(),
kind: "fact".to_owned(),
},
SandboxProposal::Retire {
memory_id: "mem_a".to_owned(),
},
],
};
let overlay = session.overlay();
assert_eq!(overlay.len(), 2, "one upsert + one remove");
}
#[test]
fn session_round_trips_through_json() {
let session = SandboxSession {
proposals: vec![SandboxProposal::Import {
memory_id: "mem_x".to_owned(),
content: "imported".to_owned(),
content_hash: content_hash("imported"),
level: "episodic".to_owned(),
kind: "fact".to_owned(),
}],
};
let json = serde_json::to_string(&session).expect("serialize");
let restored: SandboxSession = serde_json::from_str(&json).expect("deserialize");
assert_eq!(session, restored);
}
#[test]
fn assemble_diff_classifies_proposals_and_marks_approximation() {
let baseline_memories = vec![
("mem_a".to_owned(), "alpha".to_owned()),
("mem_b".to_owned(), "beta".to_owned()),
];
let new_id = synthetic_memory_id("gamma fact");
let session = SandboxSession {
proposals: vec![
SandboxProposal::Remember {
memory_id: new_id.clone(),
content: "gamma fact".to_owned(),
content_hash: content_hash("gamma fact"),
level: "episodic".to_owned(),
kind: "fact".to_owned(),
},
SandboxProposal::Retire {
memory_id: "mem_b".to_owned(),
},
],
};
let surface = assemble_sandbox_diff(&baseline_memories, &session);
assert_eq!(surface.schema, super::SANDBOX_DIFF_SCHEMA_V1);
assert_eq!(surface.added, vec![new_id]);
assert_eq!(surface.removed, vec!["mem_b".to_owned()]);
assert_eq!(surface.unchanged, 1); assert_eq!(surface.proposal_count, 2);
assert!(!surface.durable_mutation);
assert!(surface.sandbox_approximation);
assert!(surface.overlay_hash.starts_with("blake3:"));
}
#[test]
fn assemble_diff_is_deterministic() {
let baseline_memories = vec![("mem_a".to_owned(), "alpha".to_owned())];
let session = SandboxSession {
proposals: vec![SandboxProposal::Remember {
memory_id: synthetic_memory_id("new"),
content: "new".to_owned(),
content_hash: content_hash("new"),
level: "episodic".to_owned(),
kind: "fact".to_owned(),
}],
};
let first = assemble_sandbox_diff(&baseline_memories, &session);
let second = assemble_sandbox_diff(&baseline_memories, &session);
assert_eq!(first, second, "sandbox diff is deterministic");
}
}