Bind Modeling memory to live source truth

This commit is contained in:
Meta Vens 2026-07-13 18:51:38 +01:00
commit fcbab316e6
19 changed files with 583 additions and 76 deletions

View file

@ -39,7 +39,7 @@ tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
wiremock = "0.6"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"

View file

@ -27,6 +27,7 @@ use epiphany_core::memory_graph_from_epiphany_graphs;
use epiphany_core::memory_graph_from_heartbeat_cognition;
use epiphany_core::memory_graph_node_id;
use epiphany_core::plan_memory_graph_context_cut;
use epiphany_core::refresh_or_validate_repo_memory_graph;
use epiphany_core::validate_memory_graph_snapshot;
use epiphany_core::write_memory_graph_snapshot;
use std::env;
@ -291,6 +292,41 @@ fn read_refresh_args(args: impl Iterator<Item = String>) -> Result<RefreshArgs>
}
fn refresh_memory_graph_store(args: RefreshArgs) -> Result<serde_json::Value> {
if args.sources.is_empty()
&& args.agent_store.is_none()
&& args.heartbeat_store.is_none()
&& let Some(thread_state_store) = &args.thread_state_store
{
let state = load_thread_state(thread_state_store)?.ok_or_else(|| {
anyhow!(
"thread state store {} is missing",
thread_state_store.display()
)
})?;
let repo_root = thread_state_store
.parent()
.and_then(std::path::Path::parent)
.unwrap_or_else(|| std::path::Path::new("."));
let source_identity = thread_state_store
.canonicalize()
.unwrap_or_else(|_| thread_state_store.clone())
.to_string_lossy()
.into_owned();
let (snapshot, refresh) = refresh_or_validate_repo_memory_graph(
&args.store,
&source_identity,
&state,
repo_root,
args.graph_id,
)?;
return Ok(serde_json::json!({
"ok": true, "store": args.store, "graphId": snapshot.graph_id,
"source": snapshot.source, "refresh": format!("{refresh:?}").to_lowercase(),
"domains": snapshot.domains.len(), "nodes": snapshot.nodes.len(),
"edges": snapshot.edges.len(), "summaries": snapshot.summaries.len(),
"freshness": snapshot.freshness,
}));
}
let mut snapshots = Vec::new();
let mut source_status = Vec::new();
@ -341,7 +377,22 @@ fn refresh_memory_graph_store(args: RefreshArgs) -> Result<serde_json::Value> {
thread_state_store.display()
)
})?;
let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &state.graphs);
let repo_root = thread_state_store
.parent()
.and_then(std::path::Path::parent)
.unwrap_or_else(|| std::path::Path::new("."));
let source_identity = thread_state_store
.canonicalize()
.unwrap_or_else(|_| thread_state_store.clone())
.to_string_lossy()
.into_owned();
let snapshot = memory_graph_from_epiphany_graphs(
"repo-profile",
&state.graphs,
source_identity,
state.revision,
repo_root,
)?;
source_status.push(serde_json::json!({
"kind": "thread_state_store",
"source": thread_state_store,

View file

@ -5,21 +5,20 @@ use crate::EpiphanyMemoryContextPacket;
use crate::EpiphanyMemoryContextQuery;
use crate::EpiphanyMemoryProfile;
use crate::EpiphanyPromptContextInput;
use crate::RepoMemoryGraphRefresh;
use crate::RuntimeHandsReceiptChainSummary;
use crate::load_epiphany_cultmesh_cluster_topology;
use crate::load_epiphany_cultmesh_status;
use crate::load_latest_epiphany_cultmesh_work_loop_telemetry;
use crate::load_memory_graph_snapshot;
use crate::memory_graph_from_epiphany_graphs;
use crate::plan_memory_graph_context_cut;
use crate::query_epiphany_local_verse_context;
use crate::refresh_or_validate_repo_memory_graph;
use crate::render_epiphany_prompt_context;
use crate::runtime_hands_command_receipt;
use crate::runtime_hands_commit_receipt;
use crate::runtime_hands_patch_receipt;
use crate::runtime_latest_hands_receipt_chain_after;
use crate::write_epiphany_cultmesh_work_loop_telemetry;
use crate::write_memory_graph_snapshot;
use chrono::SecondsFormat;
use chrono::Utc;
use epiphany_state_model::EpiphanyAcceptanceReceipt;
@ -632,28 +631,27 @@ fn launch_memory_context(
focus: &str,
) -> Result<EpiphanyMemoryContextPacket, String> {
let memory_graph_store = memory_graph_store_path(runtime_store_path);
let (snapshot, refreshed_from_state) = match load_memory_graph_snapshot(&memory_graph_store)
.map_err(|error| {
format!(
"failed to load memory graph store {}: {error}",
memory_graph_store.display()
)
})? {
Some(snapshot) => (snapshot, false),
None => {
let snapshot = memory_graph_from_epiphany_graphs(
format!("bridge-launch-state-rev-{}", state.revision),
&state.graphs,
);
write_memory_graph_snapshot(&memory_graph_store, &snapshot).map_err(|error| {
format!(
"failed to write memory graph store {} from thread state: {error}",
memory_graph_store.display()
)
})?;
(snapshot, true)
}
};
let repo_root = runtime_store_path
.parent()
.and_then(Path::parent)
.unwrap_or_else(|| Path::new("."));
let source_identity = fs::canonicalize(runtime_store_path)
.unwrap_or_else(|_| runtime_store_path.to_path_buf())
.to_string_lossy()
.into_owned();
let (snapshot, refresh) = refresh_or_validate_repo_memory_graph(
&memory_graph_store,
&source_identity,
state,
repo_root,
format!("bridge-launch-state-rev-{}", state.revision),
)
.map_err(|error| {
format!(
"failed to refresh memory graph store {}: {error}",
memory_graph_store.display()
)
})?;
let mut packet = plan_memory_graph_context_cut(
&snapshot,
@ -665,7 +663,7 @@ fn launch_memory_context(
..Default::default()
},
);
if refreshed_from_state {
if refresh == RepoMemoryGraphRefresh::Refreshed {
packet.warnings.push(format!(
"Memory graph store was refreshed from current thread-state repo graph at {}.",
memory_graph_store.display()

View file

@ -617,6 +617,7 @@ pub use memory_graph::EpiphanyMemoryPatchCandidate;
pub use memory_graph::MEMORY_GRAPH_KEY;
pub use memory_graph::MEMORY_GRAPH_SCHEMA_VERSION;
pub use memory_graph::MEMORY_GRAPH_TYPE;
pub use memory_graph::RepoMemoryGraphRefresh;
pub use memory_graph::compose_memory_graph_snapshots;
pub use memory_graph::derive_memory_graph_freshness;
pub use memory_graph::lifecycle_allowed_for_profile;
@ -630,6 +631,7 @@ pub use memory_graph::memory_graph_from_epiphany_graphs;
pub use memory_graph::memory_graph_from_heartbeat_cognition;
pub use memory_graph::memory_graph_node_id;
pub use memory_graph::plan_memory_graph_context_cut;
pub use memory_graph::refresh_or_validate_repo_memory_graph;
pub use memory_graph::validate_memory_graph_entry;
pub use memory_graph::validate_memory_graph_snapshot;
pub use memory_graph::write_memory_graph_entry;

View file

@ -18,10 +18,12 @@ pub fn plan_memory_graph_context_cut(
query: &EpiphanyMemoryContextQuery,
) -> EpiphanyMemoryContextPacket {
let budget = query.budget.unwrap_or(12).clamp(1, 64) as usize;
let freshness = snapshot
let dirty_source_hashes = snapshot
.freshness
.clone()
.unwrap_or_else(|| derive_memory_graph_freshness(snapshot, &[]));
.as_ref()
.map(|freshness| freshness.dirty_source_hashes.as_slice())
.unwrap_or(&[]);
let freshness = derive_memory_graph_freshness(snapshot, dirty_source_hashes);
let stale_nodes: HashSet<&str> = freshness
.stale_node_ids
.iter()

View file

@ -16,6 +16,7 @@ pub use epiphany_state_model::EpiphanyMemoryEmbeddingManifest;
pub use epiphany_state_model::EpiphanyMemoryFreshness;
pub use epiphany_state_model::EpiphanyMemoryFreshnessStatus;
pub use epiphany_state_model::EpiphanyMemoryGraphSnapshot;
pub use epiphany_state_model::EpiphanyMemoryGraphSource;
pub use epiphany_state_model::EpiphanyMemoryLifecycle;
pub use epiphany_state_model::EpiphanyMemoryLifecycleReceipt;
pub use epiphany_state_model::EpiphanyMemoryNode;
@ -30,9 +31,11 @@ pub use freshness::derive_memory_graph_freshness;
pub use ids::memory_graph_domain_id;
pub use ids::memory_graph_edge_id;
pub use ids::memory_graph_node_id;
pub use profiles::RepoMemoryGraphRefresh;
pub use profiles::memory_graph_from_agent_memories;
pub use profiles::memory_graph_from_epiphany_graphs;
pub use profiles::memory_graph_from_heartbeat_cognition;
pub use profiles::refresh_or_validate_repo_memory_graph;
pub use store::EpiphanyMemoryGraphEntry;
pub use store::MEMORY_GRAPH_KEY;
pub use store::MEMORY_GRAPH_SCHEMA_VERSION;

View file

@ -92,7 +92,7 @@ pub fn memory_graph_from_agent_memories(
}
EpiphanyMemoryGraphSnapshot {
schema_version: Some("epiphany.memory_graph.v0".to_string()),
schema_version: Some("epiphany.memory_graph.v1".to_string()),
graph_id: graph_id.into(),
domains,
nodes,

View file

@ -45,7 +45,7 @@ pub fn memory_graph_from_heartbeat_cognition(
import_bridge_pressure(cognition, &mut domains, &mut nodes, &mut summaries);
EpiphanyMemoryGraphSnapshot {
schema_version: Some("epiphany.memory_graph.v0".to_string()),
schema_version: Some("epiphany.memory_graph.v1".to_string()),
graph_id: graph_id.into(),
domains,
nodes,

View file

@ -4,4 +4,6 @@ pub mod repo;
pub use agent::memory_graph_from_agent_memories;
pub use heartbeat::memory_graph_from_heartbeat_cognition;
pub use repo::RepoMemoryGraphRefresh;
pub use repo::memory_graph_from_epiphany_graphs;
pub use repo::refresh_or_validate_repo_memory_graph;

View file

@ -13,18 +13,128 @@ use crate::memory_graph::EpiphanyMemorySummary;
use crate::memory_graph::memory_graph_domain_id;
use crate::memory_graph::memory_graph_edge_id;
use crate::memory_graph::memory_graph_node_id;
use anyhow::{Context, Result};
use epiphany_state_model::EpiphanyCodeRef;
use epiphany_state_model::EpiphanyGraph;
use epiphany_state_model::EpiphanyGraphEdge;
use epiphany_state_model::EpiphanyGraphLink;
use epiphany_state_model::EpiphanyGraphNode;
use epiphany_state_model::EpiphanyGraphs;
use epiphany_state_model::EpiphanyThreadState;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use uuid::Uuid;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepoMemoryGraphRefresh {
Reused,
Refreshed,
}
pub fn refresh_or_validate_repo_memory_graph(
store_path: &Path,
source_identity: &str,
state: &EpiphanyThreadState,
repo_root: &Path,
graph_id: impl Into<String>,
) -> Result<(EpiphanyMemoryGraphSnapshot, RepoMemoryGraphRefresh)> {
let current_entry = crate::memory_graph::load_memory_graph_entry(store_path)?;
if let Some(snapshot) = current_entry
.filter(|entry| entry.schema_version == crate::memory_graph::MEMORY_GRAPH_SCHEMA_VERSION)
.and_then(|entry| entry.snapshot().ok())
.filter(|snapshot| {
snapshot.schema_version.as_deref()
== Some(crate::memory_graph::MEMORY_GRAPH_SCHEMA_VERSION)
})
{
let provenance_matches = snapshot.source.as_ref().is_some_and(|source| {
source.kind == "thread_state_repo_graph"
&& source.identity == source_identity
&& source.revision == state.revision
});
if provenance_matches && snapshot_anchor_bytes_match(&snapshot, repo_root) {
return Ok((snapshot, RepoMemoryGraphRefresh::Reused));
}
}
let snapshot = memory_graph_from_epiphany_graphs(
graph_id,
&state.graphs,
source_identity,
state.revision,
repo_root,
)?;
let temporary = store_path.with_extension(format!("refresh-{}.tmp", Uuid::new_v4()));
crate::memory_graph::write_memory_graph_snapshot(&temporary, &snapshot)?;
atomic_replace_file(&temporary, store_path).with_context(|| {
format!(
"atomically replace memory graph store {}",
store_path.display()
)
})?;
Ok((snapshot, RepoMemoryGraphRefresh::Refreshed))
}
#[cfg(windows)]
fn atomic_replace_file(source: &Path, destination: &Path) -> Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Storage::FileSystem::{
MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
};
let source = source
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
let destination = destination
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
let moved = unsafe {
MoveFileExW(
source.as_ptr(),
destination.as_ptr(),
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
)
};
if moved == 0 {
return Err(std::io::Error::last_os_error().into());
}
Ok(())
}
#[cfg(not(windows))]
fn atomic_replace_file(source: &Path, destination: &Path) -> Result<()> {
fs::rename(source, destination)?;
Ok(())
}
fn snapshot_anchor_bytes_match(snapshot: &EpiphanyMemoryGraphSnapshot, repo_root: &Path) -> bool {
snapshot
.nodes
.iter()
.flat_map(|node| &node.anchors)
.chain(snapshot.edges.iter().flat_map(|edge| &edge.anchors))
.all(|anchor| {
let (Some(code_ref), Some(expected)) = (&anchor.code_ref, &anchor.source_hash) else {
return false;
};
fs::read(repo_root.join(&code_ref.path))
.ok()
.is_some_and(|bytes| format!("sha256:{:x}", Sha256::digest(bytes)) == *expected)
})
}
pub fn memory_graph_from_epiphany_graphs(
graph_id: impl Into<String>,
graphs: &EpiphanyGraphs,
) -> EpiphanyMemoryGraphSnapshot {
source_identity: impl Into<String>,
source_revision: u64,
repo_root: &Path,
) -> Result<EpiphanyMemoryGraphSnapshot> {
let architecture_domain = memory_graph_domain_id(
EpiphanyMemoryProfile::RepoArchitecture,
"accepted_graph",
@ -54,12 +164,14 @@ pub fn memory_graph_from_epiphany_graphs(
EpiphanyMemoryProfile::RepoArchitecture,
&mut node_map,
&mut nodes,
repo_root,
);
import_graph_edges(
&graphs.architecture,
EpiphanyMemoryProfile::RepoArchitecture,
&node_map,
&mut edges,
repo_root,
);
summaries.push(repo_summary(
"summary-accepted-architecture-graph",
@ -84,12 +196,14 @@ pub fn memory_graph_from_epiphany_graphs(
EpiphanyMemoryProfile::RepoDataflow,
&mut node_map,
&mut nodes,
repo_root,
);
import_graph_edges(
&graphs.dataflow,
EpiphanyMemoryProfile::RepoDataflow,
&node_map,
&mut edges,
repo_root,
);
summaries.push(repo_summary(
"summary-accepted-dataflow-graph",
@ -102,22 +216,56 @@ pub fn memory_graph_from_epiphany_graphs(
));
}
import_graph_links(graphs.links.as_slice(), &node_map, &mut edges);
import_graph_links(graphs.links.as_slice(), &node_map, &mut edges, repo_root);
EpiphanyMemoryGraphSnapshot {
schema_version: Some("epiphany.memory_graph.v0".to_string()),
let stale_node_ids = nodes
.iter()
.filter(|node| node.lifecycle == EpiphanyMemoryLifecycle::Stale)
.map(|node| node.id.clone())
.collect::<Vec<_>>();
let stale_edge_ids = edges
.iter()
.filter(|edge| edge.lifecycle == EpiphanyMemoryLifecycle::Stale)
.map(|edge| edge.id.clone())
.collect::<Vec<_>>();
let stale = !stale_node_ids.is_empty() || !stale_edge_ids.is_empty();
if stale {
for summary in &mut summaries {
summary.freshness = EpiphanyMemoryFreshnessStatus::Stale;
}
}
Ok(EpiphanyMemoryGraphSnapshot {
schema_version: Some("epiphany.memory_graph.v1".to_string()),
graph_id: graph_id.into(),
source: Some(crate::memory_graph::EpiphanyMemoryGraphSource {
kind: "thread_state_repo_graph".to_string(),
identity: source_identity.into(),
revision: source_revision,
}),
domains,
nodes,
edges,
summaries,
freshness: Some(EpiphanyMemoryFreshness {
status: EpiphanyMemoryFreshnessStatus::Ready,
note: Some("Imported from accepted Epiphany graph state.".to_string()),
status: if stale {
EpiphanyMemoryFreshnessStatus::Stale
} else {
EpiphanyMemoryFreshnessStatus::Ready
},
stale_node_ids,
stale_edge_ids,
note: Some(
if stale {
"One or more accepted graph anchors could not be verified against source bytes."
} else {
"Imported from accepted Epiphany graph state."
}
.to_string(),
),
..Default::default()
}),
..Default::default()
}
})
}
fn repo_domain(id: String, profile: EpiphanyMemoryProfile, title: &str) -> EpiphanyMemoryDomain {
@ -136,13 +284,15 @@ fn import_graph_nodes(
profile: EpiphanyMemoryProfile,
node_map: &mut HashMap<String, String>,
nodes: &mut Vec<EpiphanyMemoryNode>,
repo_root: &Path,
) {
for node in &graph.nodes {
let memory_id = memory_graph_node_id(domain_id, "accepted_graph_node", &node.id, None);
node_map.insert(node.id.clone(), memory_id.clone());
nodes.push(memory_node_from_graph_node(
memory_id, domain_id, profile, node,
));
nodes.push(
memory_node_from_graph_node(memory_id.clone(), domain_id, profile, node, repo_root)
.unwrap_or_else(|_| stale_memory_node(memory_id, domain_id, profile, node)),
);
}
}
@ -151,14 +301,19 @@ fn memory_node_from_graph_node(
domain_id: &str,
profile: EpiphanyMemoryProfile,
node: &EpiphanyGraphNode,
) -> EpiphanyMemoryNode {
let anchors = anchors_from_code_refs(&id, &node.code_refs);
repo_root: &Path,
) -> Result<EpiphanyMemoryNode> {
let anchors = anchors_from_code_refs(&id, &node.code_refs, repo_root)?;
let source_hashes = if anchors.is_empty() {
vec!["anchor:missing".to_string()]
} else {
anchors.iter().map(|anchor| anchor.id.clone()).collect()
anchors
.iter()
.filter_map(|anchor| anchor.source_hash.clone())
.collect()
};
EpiphanyMemoryNode {
let grounded = !anchors.is_empty();
Ok(EpiphanyMemoryNode {
id,
domain_id: domain_id.to_string(),
profile,
@ -173,9 +328,37 @@ fn memory_node_from_graph_node(
),
anchors,
source_hashes,
lifecycle: graph_status_lifecycle(node.status.as_deref()),
lifecycle: if grounded {
graph_status_lifecycle(node.status.as_deref())
} else {
EpiphanyMemoryLifecycle::Stale
},
salience: 70,
confidence: 80,
confidence: if grounded { 80 } else { 0 },
..Default::default()
})
}
fn stale_memory_node(
id: String,
domain_id: &str,
profile: EpiphanyMemoryProfile,
node: &EpiphanyGraphNode,
) -> EpiphanyMemoryNode {
EpiphanyMemoryNode {
id,
domain_id: domain_id.to_string(),
profile,
kind: memory_node_kind(node),
title: node.title.clone(),
claim: graph_node_claim(node),
question: "Source anchor is unavailable; refresh after restoring it.".to_string(),
tension: "Accepted anatomy cannot be verified against source bytes.".to_string(),
action_implication: "Do not use this claim as fresh compressed anatomy.".to_string(),
source_hashes: vec!["anchor:missing".to_string()],
lifecycle: EpiphanyMemoryLifecycle::Stale,
salience: 70,
confidence: 0,
..Default::default()
}
}
@ -185,6 +368,7 @@ fn import_graph_edges(
profile: EpiphanyMemoryProfile,
node_map: &HashMap<String, String>,
edges: &mut Vec<EpiphanyMemoryEdge>,
repo_root: &Path,
) {
for edge in &graph.edges {
let source_id = node_map
@ -196,7 +380,7 @@ fn import_graph_edges(
.cloned()
.unwrap_or_else(|| edge.target_id.clone());
edges.push(memory_edge_from_graph_edge(
source_id, target_id, profile, edge,
source_id, target_id, profile, edge, repo_root,
));
}
}
@ -206,6 +390,7 @@ fn memory_edge_from_graph_edge(
target_id: String,
profile: EpiphanyMemoryProfile,
edge: &EpiphanyGraphEdge,
repo_root: &Path,
) -> EpiphanyMemoryEdge {
let id = edge.id.clone().unwrap_or_else(|| {
memory_graph_edge_id(
@ -215,6 +400,12 @@ fn memory_edge_from_graph_edge(
edge.code_refs.iter().map(code_ref_key),
)
});
let anchors = anchors_from_code_refs(
edge.id.as_deref().unwrap_or("edge"),
&edge.code_refs,
repo_root,
);
let grounded = edge.code_refs.is_empty() || anchors.is_ok();
EpiphanyMemoryEdge {
id,
source_id,
@ -222,9 +413,13 @@ fn memory_edge_from_graph_edge(
kind: memory_edge_kind(edge.kind.as_str()),
profile,
claim: graph_edge_claim(edge),
anchors: anchors_from_code_refs(edge.id.as_deref().unwrap_or("edge"), &edge.code_refs),
lifecycle: EpiphanyMemoryLifecycle::Accepted,
confidence: 80,
anchors: anchors.unwrap_or_default(),
lifecycle: if grounded {
EpiphanyMemoryLifecycle::Accepted
} else {
EpiphanyMemoryLifecycle::Stale
},
confidence: if grounded { 80 } else { 0 },
}
}
@ -232,6 +427,7 @@ fn import_graph_links(
links: &[EpiphanyGraphLink],
node_map: &HashMap<String, String>,
edges: &mut Vec<EpiphanyMemoryEdge>,
repo_root: &Path,
) {
for link in links {
let Some(source_id) = node_map.get(&link.dataflow_node_id).cloned() else {
@ -240,6 +436,8 @@ fn import_graph_links(
let Some(target_id) = node_map.get(&link.architecture_node_id).cloned() else {
continue;
};
let anchors = anchors_from_code_refs("link", &link.code_refs, repo_root);
let grounded = link.code_refs.is_empty() || anchors.is_ok();
edges.push(EpiphanyMemoryEdge {
id: memory_graph_edge_id(
&source_id,
@ -255,9 +453,13 @@ fn import_graph_links(
.relationship
.clone()
.unwrap_or_else(|| "Dataflow node is linked to architecture node.".to_string()),
anchors: anchors_from_code_refs("link", &link.code_refs),
lifecycle: EpiphanyMemoryLifecycle::Accepted,
confidence: 75,
anchors: anchors.unwrap_or_default(),
lifecycle: if grounded {
EpiphanyMemoryLifecycle::Accepted
} else {
EpiphanyMemoryLifecycle::Stale
},
confidence: if grounded { 75 } else { 0 },
});
}
}
@ -306,17 +508,23 @@ fn repo_summary(
fn anchors_from_code_refs(
prefix: &str,
code_refs: &[EpiphanyCodeRef],
) -> Vec<EpiphanyMemoryAnchor> {
repo_root: &Path,
) -> Result<Vec<EpiphanyMemoryAnchor>> {
code_refs
.iter()
.enumerate()
.map(|(index, code_ref)| EpiphanyMemoryAnchor {
id: format!("anchor-{prefix}-{index}"),
kind: "code_ref".to_string(),
target: code_ref_key(code_ref),
code_ref: Some(code_ref.clone()),
source_hash: Some(code_ref_key(code_ref)),
..Default::default()
.map(|(index, code_ref)| {
let bytes = fs::read(repo_root.join(&code_ref.path))
.with_context(|| format!("missing source anchor {}", code_ref.path.display()))?;
let digest = format!("sha256:{:x}", Sha256::digest(&bytes));
Ok(EpiphanyMemoryAnchor {
id: format!("anchor-{prefix}-{index}"),
kind: "code_ref".to_string(),
target: code_ref_key(code_ref),
code_ref: Some(code_ref.clone()),
source_hash: Some(digest),
..Default::default()
})
})
.collect()
}
@ -429,7 +637,14 @@ mod tests {
..Default::default()
};
let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &graphs);
let snapshot = memory_graph_from_epiphany_graphs(
"repo-profile",
&graphs,
"test-state",
1,
Path::new("."),
)
.unwrap();
let errors = validate_memory_graph_snapshot(&snapshot);
assert!(errors.is_empty(), "{errors:?}");
@ -458,7 +673,14 @@ mod tests {
..Default::default()
};
let snapshot = memory_graph_from_epiphany_graphs("repo-profile", &graphs);
let snapshot = memory_graph_from_epiphany_graphs(
"repo-profile",
&graphs,
"test-state",
1,
Path::new("."),
)
.unwrap();
let errors = validate_memory_graph_snapshot(&snapshot);
assert!(
@ -472,4 +694,109 @@ mod tests {
.any(|error| error.message.contains("missing target node"))
);
}
fn anchored_state(path: &str, revision: u64) -> EpiphanyThreadState {
EpiphanyThreadState {
revision,
graphs: EpiphanyGraphs {
architecture: EpiphanyGraph {
nodes: vec![EpiphanyGraphNode {
id: "body".to_string(),
title: "Body".to_string(),
purpose: "Grounded body anatomy.".to_string(),
code_refs: vec![EpiphanyCodeRef {
path: PathBuf::from(path),
..Default::default()
}],
..Default::default()
}],
..Default::default()
},
..Default::default()
},
..Default::default()
}
}
#[test]
fn repo_cache_reuse_requires_revision_and_anchor_bytes() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
std::fs::write(temp.path().join("body.rs"), "one")?;
let store = temp.path().join("memory-graph.msgpack");
let state = anchored_state("body.rs", 1);
let (first, first_refresh) =
refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?;
assert_eq!(first_refresh, RepoMemoryGraphRefresh::Refreshed);
assert!(first.nodes[0].source_hashes[0].starts_with("sha256:"));
let (same, same_refresh) =
refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?;
assert_eq!(same_refresh, RepoMemoryGraphRefresh::Reused);
assert_eq!(first.source, same.source);
std::fs::write(temp.path().join("body.rs"), "two")?;
let (changed, bytes_refresh) =
refresh_or_validate_repo_memory_graph(&store, "thread", &state, temp.path(), "repo")?;
assert_eq!(bytes_refresh, RepoMemoryGraphRefresh::Refreshed);
assert_ne!(first.nodes[0].source_hashes, changed.nodes[0].source_hashes);
let next = anchored_state("body.rs", 2);
let (revised, revision_refresh) =
refresh_or_validate_repo_memory_graph(&store, "thread", &next, temp.path(), "repo")?;
assert_eq!(revision_refresh, RepoMemoryGraphRefresh::Refreshed);
assert_eq!(revised.source.unwrap().revision, 2);
Ok(())
}
#[test]
fn missing_anchor_cannot_emit_ready_summary() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let snapshot = memory_graph_from_epiphany_graphs(
"repo",
&anchored_state("gone.rs", 1).graphs,
"thread",
1,
temp.path(),
)?;
assert_eq!(
snapshot.freshness.unwrap().status,
EpiphanyMemoryFreshnessStatus::Stale
);
assert!(
snapshot
.summaries
.iter()
.all(|summary| summary.freshness == EpiphanyMemoryFreshnessStatus::Stale)
);
Ok(())
}
#[test]
fn missing_edge_anchor_stales_edge_summary_and_snapshot() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
std::fs::write(temp.path().join("body.rs"), "body")?;
let mut state = anchored_state("body.rs", 1);
state.graphs.architecture.edges.push(EpiphanyGraphEdge {
id: Some("edge-body".to_string()),
source_id: "body".to_string(),
target_id: "body".to_string(),
kind: "owns".to_string(),
code_refs: vec![EpiphanyCodeRef {
path: PathBuf::from("missing-edge.rs"),
..Default::default()
}],
..Default::default()
});
let snapshot =
memory_graph_from_epiphany_graphs("repo", &state.graphs, "thread", 1, temp.path())?;
assert_eq!(snapshot.edges[0].lifecycle, EpiphanyMemoryLifecycle::Stale);
assert_eq!(
snapshot.summaries[0].freshness,
EpiphanyMemoryFreshnessStatus::Stale
);
let freshness = snapshot.freshness.expect("freshness");
assert_eq!(freshness.status, EpiphanyMemoryFreshnessStatus::Stale);
assert_eq!(freshness.stale_edge_ids, vec!["edge-body".to_string()]);
Ok(())
}
}

View file

@ -1,4 +1,6 @@
use super::EpiphanyMemoryFreshnessStatus;
use super::EpiphanyMemoryGraphSnapshot;
use super::derive_memory_graph_freshness;
use super::validate_memory_graph_snapshot;
use anyhow::Result;
use anyhow::anyhow;
@ -9,7 +11,7 @@ use std::path::Path;
pub const MEMORY_GRAPH_TYPE: &str = "epiphany.memory_graph";
pub const MEMORY_GRAPH_KEY: &str = "default";
pub const MEMORY_GRAPH_SCHEMA_VERSION: &str = "epiphany.memory_graph.v0";
pub const MEMORY_GRAPH_SCHEMA_VERSION: &str = "epiphany.memory_graph.v1";
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "epiphany.memory_graph", schema = "EpiphanyMemoryGraphEntry")]
@ -89,6 +91,13 @@ pub fn validate_memory_graph_entry(entry: &EpiphanyMemoryGraphEntry) -> Result<(
));
}
let snapshot = entry.snapshot()?;
if snapshot.schema_version.as_deref() != Some(MEMORY_GRAPH_SCHEMA_VERSION) {
return Err(anyhow!(
"memory graph snapshot schema_version is {:?}, expected {:?}",
snapshot.schema_version,
MEMORY_GRAPH_SCHEMA_VERSION
));
}
if entry.graph_id != snapshot.graph_id {
return Err(anyhow!(
"memory graph entry graph_id {:?} does not match snapshot graph_id {:?}",
@ -105,5 +114,29 @@ pub fn validate_memory_graph_entry(entry: &EpiphanyMemoryGraphEntry) -> Result<(
.join("; ");
return Err(anyhow!("memory graph validation failed: {message}"));
}
let dirty = snapshot
.freshness
.as_ref()
.map(|freshness| freshness.dirty_source_hashes.as_slice())
.unwrap_or(&[]);
let derived = derive_memory_graph_freshness(&snapshot, dirty);
if snapshot.freshness.as_ref().is_some_and(|stored| {
stored.status != derived.status
|| stored.stale_node_ids != derived.stale_node_ids
|| stored.stale_edge_ids != derived.stale_edge_ids
|| stored.stale_summary_ids != derived.stale_summary_ids
}) {
return Err(anyhow!(
"memory graph stored freshness disagrees with derived lifecycle/source freshness"
));
}
if snapshot.summaries.iter().any(|summary| {
derived.stale_summary_ids.contains(&summary.id)
&& summary.freshness != EpiphanyMemoryFreshnessStatus::Stale
}) {
return Err(anyhow!(
"memory graph has a ready summary covering stale anatomy"
));
}
Ok(())
}

View file

@ -23,6 +23,7 @@ fn fixture_snapshot() -> EpiphanyMemoryGraphSnapshot {
let node_b = memory_graph_node_id(&domain_id, "module", "src/memory_graph.rs", Some("b"));
let edge = memory_graph_edge_id(&node_a, &node_b, "owns", ["src/memory_graph.rs"]);
EpiphanyMemoryGraphSnapshot {
schema_version: Some(MEMORY_GRAPH_SCHEMA_VERSION.to_string()),
graph_id: "graph".to_string(),
domains: vec![EpiphanyMemoryDomain {
id: domain_id.clone(),
@ -197,6 +198,15 @@ fn memory_graph_entry_validation_rejects_mismatched_identity() {
assert!(error.to_string().contains("does not match snapshot"));
}
#[test]
fn memory_graph_entry_rejects_embedded_legacy_schema() {
let mut snapshot = fixture_snapshot();
snapshot.schema_version = Some("epiphany.memory_graph.v0".to_string());
let entry = EpiphanyMemoryGraphEntry::from_snapshot(&snapshot).expect("snapshot entry");
let error = validate_memory_graph_entry(&entry).expect_err("embedded v0 is invalid");
assert!(error.to_string().contains("snapshot schema_version"));
}
#[test]
fn memory_graph_profile_law_keeps_role_memory_from_repo_lifecycle() {
assert!(lifecycle_allowed_for_profile(
@ -362,6 +372,33 @@ fn memory_graph_context_cut_descends_when_summary_is_stale() {
);
}
#[test]
fn memory_graph_context_cut_rederives_forged_ready_freshness() {
let mut snapshot = fixture_snapshot();
snapshot.nodes[0].lifecycle = EpiphanyMemoryLifecycle::Stale;
snapshot.freshness = Some(EpiphanyMemoryFreshness {
status: EpiphanyMemoryFreshnessStatus::Ready,
note: Some("forged cache claim".to_string()),
..Default::default()
});
let packet = plan_memory_graph_context_cut(
&snapshot,
&EpiphanyMemoryContextQuery {
id: "query".to_string(),
profile: Some(EpiphanyMemoryProfile::RepoArchitecture),
text: Some("shared graph law".to_string()),
..Default::default()
},
);
assert!(packet.summaries.is_empty());
assert!(
packet
.warnings
.iter()
.any(|warning| warning.contains("descended"))
);
}
#[test]
fn memory_graph_context_cut_reports_missing_explicit_ids() {
let snapshot = fixture_snapshot();

View file

@ -728,6 +728,9 @@ pub struct EpiphanyMemoryGraphSnapshot {
#[ts(type = "string | null")]
pub schema_version: Option<String>,
pub graph_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "EpiphanyMemoryGraphSource | null")]
pub source: Option<EpiphanyMemoryGraphSource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[ts(type = "Array<EpiphanyMemoryDomain>")]
pub domains: Vec<EpiphanyMemoryDomain>,
@ -751,6 +754,13 @@ pub struct EpiphanyMemoryGraphSnapshot {
pub lifecycle_receipts: Vec<EpiphanyMemoryLifecycleReceipt>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS)]
pub struct EpiphanyMemoryGraphSource {
pub kind: String,
pub identity: String,
pub revision: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
pub struct EpiphanyMemoryDomain {
pub id: String,

View file

@ -1618,6 +1618,29 @@ local Verse, result/checked ref, watched ref, source commit, runbook commit, and
current HEAD. Readiness re-resolves the underlying deployment receipt; absence
or any mismatch remains incomplete.
## Cache existence is not fresh Modeling anatomy
The persistent repo memory graph was reused indefinitely whenever
`memory-graph.msgpack` existed. Repo import stamped anatomy `Ready`, while its
so-called source hashes were only `path#symbol` locators. Launch context could
therefore inject stale compressed summaries after accepted state revisions or
source bytes changed.
`epiphany.memory_graph.v1` now carries typed source identity and accepted state
revision. Repo anchors use SHA-256 source-byte digests. Launch and explicit
thread-state refresh share one refresh/validate primitive; reuse requires the
same state-store identity, revision, schema, and every anchored source digest.
Missing or changed node, edge, or link anchors become stale, and context cuts
derive freshness from lifecycle/source evidence rather than trusting a cached
freshness claim. Replacement is atomic on Windows and Unix. The graph embedding
manifest still has no production indexer; Modeling semantic-index availability
is therefore `unavailable`, not borrowed from generic workspace retrieval.
The independent Eyes pass found the next P0 frontier: repo-work `run_adopt`
locally authors a document claiming Mind review and then approves Hands from
field predicates. The existing typed Mind gateway must own an immutable review
bound to the exact plan; Self/scheduler may route it but may not impersonate it.
## Ambient command identity is not a typed crossing command
Bifrost's source establishes one typed command -> one crossing attempt -> one

View file

@ -55,6 +55,19 @@ readiness no longer launders its cached projection. Typed Idunn deployment and
aftercare receipts must resolve and bind across identity, runtime, Verse,
result/ref, runbook, source commit, and current HEAD.
The next Modeling pass repaired the targeting organ itself. Memory-graph v1
binds repo anatomy to the actual state-store identity, accepted revision, and
SHA-256 source bytes. Cache presence, path/symbol locators, and serialized
freshness no longer make anatomy Ready. Launch and explicit refresh share one
validator; missing/changed node, edge, or link anchors stale their summaries.
There is still no production semantic index for graph claims, so Modeling index
availability is honestly unavailable rather than inferred from code search.
Next P0 cut: `epiphany-work run_adopt` currently synthesizes Mind ownership and
an approved Hands review from local JSON predicates. Route adoption through the
existing typed Mind gateway, immutably bound to the exact plan, and delete the
scheduler's ability to author Mind/Hands consequence.
Epiphany is in an authority-provenance purification pass. The live question is not whether a command can produce a plausible document; it is whether the subsystem writing that document owns the fact it asserts.
The confirmed conceptual substitutions have been cut:

View file

@ -4,17 +4,22 @@
"title": "Epiphany Memory Graph",
"description": "Unified typed memory graph for repo architecture/dataflow, role memory, heartbeat pressure, incubation, agency, candidate interventions, and evidence. Embeddings are cache metadata, not canonical truth.",
"type": "object",
"additionalProperties": true,
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": [
"string",
"null"
]
},
"schemaVersion": { "const": "epiphany.memory_graph.v1" },
"graphId": {
"type": "string"
},
"source": {
"type": ["object", "null"],
"additionalProperties": false,
"required": ["kind", "identity", "revision"],
"properties": {
"kind": { "type": "string" },
"identity": { "type": "string" },
"revision": { "type": "integer", "minimum": 0 }
}
},
"domains": {
"type": "array",
"items": {
@ -66,6 +71,7 @@
}
},
"required": [
"schemaVersion",
"graphId"
]
}

View file

@ -293,7 +293,7 @@
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.memory_graph.v0",
"schemaVersion": "epiphany.memory_graph.v1",
"documentType": "epiphany.memory_graph",
"title": "Epiphany Memory Graph",
"path": "epiphany.memory-graph.schema.json"

Binary file not shown.

View file

@ -661,4 +661,4 @@ current_status:
- Service lifecycle receipt history is now event-only: `load_epiphany_cultmesh_daemon_service_lifecycle_receipts` filters out the `epiphany-local/daemon-service-lifecycle-receipt/latest` mirror, while `load_latest_epiphany_cultmesh_daemon_service_lifecycle_receipt` remains the latest-state reader. Focused unit test `service_lifecycle_receipt_history_excludes_latest_mirror` proves two per-receipt writes return exactly two history records and latest points at the second. Fresh smoke `verse-query-lifecycle-history-filter-smoke` and live overview/triage `local-20260618-145632-497-efac6f0d` / `local-20260618-145632-321-867947af` prove compact swarm sights still expose service lifecycle attention plus the present elevated runbook artifact without daemon pokes or service mutation. Do not use a broad unscoped cargo test filter for this check on Windows; it fans out across bin harnesses and hit a broken pipe during this pass.
- `epiphany-verse-query seed-compact` is the compact seed receipt for wrappers and read-only rites. The older `seed` command still prints the full local Verse context for explicit debugging, but compact wrapper modes now avoid full `seed`/final `query` readback after the combined overview proof exposed Windows debug stack overflow from full-context serialization pressure.
last_completed_repo_swarm_cut: Huginn live-fire has crossed the first real repo Body from Persona pressure to branch-local Hands commit and Soul/Mind closure, and now has repeatable closure and redacted-public-proof mouths. On `E:\Projects\Huginn` branch `epiphany/huginn/first-awakening`, `tools/epiphany_local_run.ps1 -Mode repo-persona-intake` accepted item `huginn-inspection-readback-livefire`, Weksa/Persona intake remained sealed, and `epiphany-work derive-plan` lowered it to model-authored safe family `repo.status_section` for `README.md` only. `tools/epiphany_local_run.ps1 -Mode repo-swarm-run` advanced four bounded pulses: run-from-plan, adopt-from-plan, execute-from-plan, and close-from-execute. Huginn commit `e9b07c38fceabbdc89ea35c2156326c80c0393a2` updated only `README.md`; closure receipt `.epiphany\work\work-close-huginn-inspection-readback-livefire.json` records status `closed`, Soul verdict `passed`, Mind state commit `repo-work-close-huginn-inspection-readback-livefire-mind-commit`, safe family `repo.status_section`, path scope `README.md`, Bifrost publication gate, and `privateStateExposed=false`. Verification also ran Huginn's TypeScript compiler through concrete Node path: `C:\Program Files\nodejs\node.exe E:\Projects\Huginn\node_modules\typescript\bin\tsc -p tsconfig.json` passed. Native verifier `epiphany-repo-livefire-closure-smoke --workspace E:\Projects\Huginn --item huginn-inspection-readback-livefire --expected-path README.md --expected-family repo.status_section --expected-gate awaiting-publication --expected-blocker bifrost-publication-missing` checks closure receipt, Mind map entry, overview gate/blocker, authority seals, and `git show` changed paths, returning `status=ok`, `soulVerdict=passed`, `publicationGate=Bifrost`, and `privateStateExposed=false`. Wrapper `tools/epiphany_local_run.ps1 -Mode repo-livefire-closure -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exposes the same read-only proof through the local operator mouth; artifact `local-20260621-190156-803-2f85ba15` printed commit, path, gate, blocker, Soul verdict, Mind commit receipt, and `privateStateExposed=False` without push, merge, publication, or target mutation. New wrapper `tools/epiphany_local_run.ps1 -Mode repo-work-public-proof -Workspace E:\Projects\Huginn -RepoWorkItem huginn-inspection-readback-livefire -SkipBuild` exports the redacted proof bundle and repo-local Verse index without publishing; artifact `local-20260621-190645-345-4555b1dc` wrote `.epiphany\public\proof-bundles\repo-work-public-proof-huginn-inspection-readback-livefire.json`, proof id `repo-work-public-proof-huginn-inspection-readback-livefire`, SHA-256 `857545b199f3e37eb06c2e757e5cfc201958e46667bb1f8403fd333a22620f63`, nine artifact rows, zero publication rows, and `privateStateExposed=False`. swarm overview readback `local-20260621-190701-140-b9519ec6` sees the `REPO-WORK-PUBLIC-PROOF` row and action row from Huginn's repo-local Verse with the same SHA-256 while Bifrost publication remains missing. Launcher compact modes are now named compact operator-context modes instead of read-only modes, and `bifrost-public-proof` summaries print item, target public Verse, room, proof SHA-256, review receipts, and credit receipts so publication receipts are auditable without claiming upstream sync.
next_action: Continue the observation-to-consequence audit using live organ-shaped prompt probes. Preserve the Aetheria dogfood lesson: Modeling's typed persistent anatomy, freshness, frontier, and semantic-index knowledgebase is the targeting organ for Hands, not a transient prelude. Idunn managed-child sight now feeds swarm overview as typed renderer-neutral rows, with executable identity required before PID liveness can satisfy recovery. Deployment/aftercare completion now accepts only a conjunctively bound typed Idunn CultMesh chain; raw response files and cached projection laundering are deleted. Model the next live frontier before cutting it. The intentionally absent Other-world surface action still requires a typed intent contract and real command owner; never advertise a wish. Keep closure's zero-substring invariant.
next_action: Cut repo-work Mind impersonation. `epiphany-work run_adopt` currently manufactures a Mind-owned adoption document and approved Hands review from local JSON predicates. Self/scheduler may route a plan, but only a resolved typed Mind gateway review immutably bound to the exact plan may authorize the admission primitive that creates Hands review. Delete the synthetic owner/interpreter path and prove that a structurally plausible plan without Mind receipt creates neither adoption nor Hands authority. Preserve the Aetheria lesson: memory-graph v1 now binds Modeling anatomy to state-store identity, accepted revision, and SHA-256 source bytes; launch and explicit refresh share validation; missing/changed anchors stale summaries. Modeling semantic graph indexing remains honestly unavailable until a real graph-claim indexer exists. The intentionally absent Other-world action still requires a typed intent and real owner. Keep closure's zero-substring invariant.