Make semantic projection causality explicit

This commit is contained in:
Meta Vens 2026-07-15 04:15:45 +01:00
commit 3be5e5b52b
11 changed files with 740 additions and 21 deletions

View file

@ -17,20 +17,83 @@ use std::path::Path;
pub const AGENT_MEMORY_TYPE: &str = "epiphany.agent_memory";
pub const AGENT_MEMORY_SCHEMA_VERSION: &str = "ghostlight.agent_state.v0";
pub const AGENT_MEMORY_SWARM_IDENTITY_TYPE: &str = "epiphany.agent_memory_swarm_identity";
pub const AGENT_MEMORY_SWARM_IDENTITY_SCHEMA_VERSION: &str =
"epiphany.agent_memory_swarm_identity.v0";
pub const AGENT_MEMORY_SWARM_IDENTITY_KEY: &str = "swarm-identity";
pub const AGENT_STATE_SOA_TYPE: &str = "epiphany.agent_state_soa";
pub const AGENT_STATE_SOA_SCHEMA_VERSION: &str = "epiphany.agent_state_soa.v0";
pub const AGENT_STATE_SOA_KEY: &str = "swarm";
pub fn swarm_identity_from_agent_memories(entries: &[EpiphanyAgentMemoryEntry]) -> String {
let mut identities = entries
.iter()
.map(|entry| format!("{}:{}", entry.role_id, entry.agent.agent_id))
.collect::<Vec<_>>();
identities.sort();
format!(
"swarm-{}",
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, identities.join("\n").as_bytes(),)
)
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(
type = "epiphany.agent_memory_swarm_identity",
schema = "AgentMemorySwarmIdentity"
)]
pub struct AgentMemorySwarmIdentity {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub swarm_id: String,
}
pub fn load_agent_memory_swarm_identity(
store_path: impl AsRef<Path>,
) -> Result<Option<AgentMemorySwarmIdentity>> {
let store_path = store_path.as_ref();
if !store_path.exists() {
return Ok(None);
}
let mut cache = agent_memory_cache(store_path)?;
cache.pull_all_backing_stores()?;
cache.get(AGENT_MEMORY_SWARM_IDENTITY_KEY)
}
pub fn ensure_agent_memory_swarm_identity(
store_path: impl AsRef<Path>,
swarm_id: &str,
) -> Result<AgentMemorySwarmIdentity> {
let swarm_id = swarm_id.trim();
if swarm_id.is_empty() {
return Err(anyhow!(
"agent memory swarm identity requires a non-empty swarm_id"
));
}
let store_path = store_path.as_ref();
let mut cache = agent_memory_cache(store_path)?;
cache.pull_all_backing_stores()?;
if let Some(existing) =
cache.get::<AgentMemorySwarmIdentity>(AGENT_MEMORY_SWARM_IDENTITY_KEY)?
{
if existing.swarm_id == swarm_id
&& existing.schema_version == AGENT_MEMORY_SWARM_IDENTITY_SCHEMA_VERSION
{
return Ok(existing);
}
return Err(anyhow!(
"agent memory swarm identity collision: store owns {:?}, refused {:?}",
existing.swarm_id,
swarm_id
));
}
let identity = AgentMemorySwarmIdentity {
schema_version: AGENT_MEMORY_SWARM_IDENTITY_SCHEMA_VERSION.to_string(),
swarm_id: swarm_id.to_string(),
};
let envelope = cache
.prepare_entry(AGENT_MEMORY_SWARM_IDENTITY_KEY, &identity)?
.0;
let backing = SingleFileMessagePackBackingStore::new(store_path);
if !backing.compare_and_swap_batch(&[], vec![envelope])? {
let raced = load_agent_memory_swarm_identity(store_path)?;
if raced.as_ref() == Some(&identity) {
return Ok(identity);
}
return Err(anyhow!(
"agent memory swarm identity lost immutable compare-and-swap"
));
}
Ok(identity)
}
const ROLE_TARGETS: &[(&str, &str, &str)] = &[
@ -1466,6 +1529,14 @@ pub fn agent_memory_status(store_path: impl AsRef<Path>) -> Result<Value> {
});
}
let errors = validate_agent_memory_store(store_path)?;
let swarm_identity = cache
.get::<AgentMemorySwarmIdentity>(AGENT_MEMORY_SWARM_IDENTITY_KEY)?
.map(|identity| {
serde_json::json!({
"schemaVersion": identity.schema_version,
"swarmId": identity.swarm_id,
})
});
let agent_state_soa = project_agent_state_soa_from_cache(store_path, &cache)?;
let persisted_agent_state_soa = cache
.get::<EpiphanyAgentStateSoaEntry>(AGENT_STATE_SOA_KEY)?
@ -1482,6 +1553,8 @@ pub fn agent_memory_status(store_path: impl AsRef<Path>) -> Result<Value> {
"store": store_path,
"present": true,
"entryType": AGENT_MEMORY_TYPE,
"swarmIdentityEntryType": AGENT_MEMORY_SWARM_IDENTITY_TYPE,
"swarmIdentity": swarm_identity,
"agentStateSoaEntryType": AGENT_STATE_SOA_TYPE,
"errors": errors,
"agentStateSoa": {
@ -1901,6 +1974,7 @@ pub fn project_agent_memory_to_json_dir(
fn agent_memory_cache(store_path: &Path) -> Result<CultCache> {
let mut cache = CultCache::new();
cache.register_entry_type::<AgentMemorySwarmIdentity>()?;
cache.register_entry_type::<EpiphanyAgentMemoryEntry>()?;
cache.register_entry_type::<EpiphanyAgentStateSoaEntry>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(store_path));
@ -2996,3 +3070,39 @@ mod tests {
.to_string()
}
}
#[test]
fn immutable_swarm_identity_separates_stores_and_refuses_collision() -> Result<()> {
let temp = tempfile::tempdir()?;
let first_store = temp.path().join("first.msgpack");
let second_store = temp.path().join("second.msgpack");
let first = ensure_agent_memory_swarm_identity(&first_store, "swarm-alpha")?;
let second = ensure_agent_memory_swarm_identity(&second_store, "swarm-beta")?;
assert_eq!(
load_agent_memory_swarm_identity(&first_store)?,
Some(first.clone())
);
assert_eq!(
ensure_agent_memory_swarm_identity(&first_store, "swarm-alpha")?,
first
);
let collision = ensure_agent_memory_swarm_identity(&first_store, "swarm-beta")
.expect_err("immutable store identity must refuse substitution");
assert!(collision.to_string().contains("collision"));
assert_ne!(
crate::semantic_point_id(
&first.swarm_id,
crate::SemanticPartition::Mind,
AGENT_MEMORY_TYPE,
"Persona",
"memory-1"
),
crate::semantic_point_id(
&second.swarm_id,
crate::SemanticPartition::Mind,
AGENT_MEMORY_TYPE,
"Persona",
"memory-1"
)
);
Ok(())
}

View file

@ -5,6 +5,7 @@ use epiphany_core::AgentSelfPatch;
use epiphany_core::agent_memory_status;
use epiphany_core::apply_agent_memory_lifecycle_operation;
use epiphany_core::apply_agent_self_patch_document;
use epiphany_core::ensure_agent_memory_swarm_identity;
use epiphany_core::migrate_agent_memory_json_dir_to_cultcache;
use epiphany_core::project_agent_memory_to_json_dir;
use epiphany_core::project_persona_state_for_role;
@ -47,6 +48,11 @@ fn main() -> Result<()> {
let store = require_path_arg(&mut args, "--store")?;
print_json(&agent_memory_status(store)?)?;
}
"set-swarm-identity" => {
let store = require_path_arg(&mut args, "--store")?;
let swarm_id = require_string_arg(&mut args, "--swarm-id")?;
print_json(&ensure_agent_memory_swarm_identity(store, &swarm_id)?)?;
}
"refresh-soa" => {
let store = require_path_arg(&mut args, "--store")?;
print_json(&refresh_agent_state_soa(store)?)?;
@ -192,7 +198,7 @@ fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
fn print_usage() {
eprintln!(
"usage: epiphany-agent-memory-store <migrate-json-dir|project-json-dir|project-persona|status|refresh-soa|repair|validate|review-patch|apply-patch|review-lifecycle|apply-lifecycle|smoke> ..."
"usage: epiphany-agent-memory-store <migrate-json-dir|project-json-dir|project-persona|set-swarm-identity|status|refresh-soa|repair|validate|review-patch|apply-patch|review-lifecycle|apply-lifecycle|smoke> ..."
);
}

View file

@ -167,16 +167,17 @@ impl Options {
if entries.is_empty() {
return Err(anyhow!("agent store has no admitted organ memory"));
}
let derived_swarm_id = epiphany_core::swarm_identity_from_agent_memories(&entries);
if self.swarm_id != derived_swarm_id {
let identity = epiphany_core::load_agent_memory_swarm_identity(path)?
.ok_or_else(|| anyhow!("agent store has no immutable swarm identity; run epiphany-agent-memory-store set-swarm-identity"))?;
if self.swarm_id != identity.swarm_id {
return Err(anyhow!(
"--swarm-id {:?} does not match the canonical agent-store swarm identity {:?}",
self.swarm_id,
derived_swarm_id
identity.swarm_id
));
}
return Ok(memory_graph_from_agent_memories(
format!("{derived_swarm_id}-mind"),
format!("{}-mind", identity.swarm_id),
&entries,
));
}

View file

@ -1112,6 +1112,28 @@ fn persona_memory_recall_for_scheduled_turn(
});
};
let swarm_identity = match crate::load_agent_memory_swarm_identity(agent_store) {
Ok(Some(identity)) => identity,
Ok(None) => {
return serde_json::json!({
"schemaVersion": crate::SEMANTIC_PROJECTION_SCHEMA_VERSION,
"status": "unavailable",
"cacheStatus": "swarm-identity-missing",
"renderedRecall": "- semantic Persona memory recall unavailable: agent store has no immutable swarm identity",
"privateStateExposed": false,
});
}
Err(error) => {
return serde_json::json!({
"schemaVersion": crate::SEMANTIC_PROJECTION_SCHEMA_VERSION,
"status": "unavailable",
"cacheStatus": "swarm-identity-load-failed",
"renderedRecall": format!("- semantic Persona memory recall unavailable: {}", compact_heartbeat_line(&format!("{error:#}"), 320)),
"privateStateExposed": false,
});
}
};
let entry = match crate::agent_memory::load_agent_memory_entry_for_role(agent_store, "Persona")
{
Ok(Some(entry)) => entry,
@ -1142,7 +1164,7 @@ fn persona_memory_recall_for_scheduled_turn(
entries.push(role_entry);
}
}
let swarm_id = crate::swarm_identity_from_agent_memories(&entries);
let swarm_id = swarm_identity.swarm_id;
let graph = crate::memory_graph_from_agent_memories(&format!("{swarm_id}-mind"), &entries);
let persona_domain_id =
crate::memory_graph_domain_id(crate::EpiphanyMemoryProfile::RoleSelf, "role", "Persona");
@ -3845,6 +3867,7 @@ mod tests {
let agent_store = temp.path().join("agents.msgpack");
let artifact_dir = temp.path().join("artifacts");
initialize_heartbeat_store(&store_path, 1.0)?;
crate::ensure_agent_memory_swarm_identity(&agent_store, "swarm-persona-memory-test")?;
crate::write_agent_memory_entry_for_role(&agent_store, &persona_memory_entry())?;
queue_heartbeat_pending_mention_store(
&store_path,

View file

@ -74,11 +74,15 @@ pub use agent_launch::epiphany_worker_prompt;
pub use agent_launch::render_epiphany_coordinator_note;
pub use agent_launch::render_epiphany_pre_compaction_checkpoint_intervention;
pub use agent_launch::unique_strings;
pub use agent_memory::AGENT_MEMORY_SWARM_IDENTITY_KEY;
pub use agent_memory::AGENT_MEMORY_SWARM_IDENTITY_SCHEMA_VERSION;
pub use agent_memory::AGENT_MEMORY_SWARM_IDENTITY_TYPE;
pub use agent_memory::AgentCanonicalTraitSeed;
pub use agent_memory::AgentMemoryBundle;
pub use agent_memory::AgentMemoryLifecycleAction;
pub use agent_memory::AgentMemoryLifecycleOperation;
pub use agent_memory::AgentMemoryReview;
pub use agent_memory::AgentMemorySwarmIdentity;
pub use agent_memory::AgentSelfPatch;
pub use agent_memory::EpiphanyAgentMemoryEntry;
pub use agent_memory::EpiphanyAgentStateSoaEntry;
@ -104,7 +108,9 @@ pub use agent_memory::apply_agent_canonical_trait_seeds;
pub use agent_memory::apply_agent_memory_lifecycle_operation;
pub use agent_memory::apply_agent_self_patch;
pub use agent_memory::apply_agent_self_patch_document;
pub use agent_memory::ensure_agent_memory_swarm_identity;
pub use agent_memory::load_agent_memory_entry_for_role;
pub use agent_memory::load_agent_memory_swarm_identity;
pub use agent_memory::load_agent_state_soa_entry;
pub use agent_memory::migrate_agent_memory_json_dir_to_cultcache;
pub use agent_memory::organ_state_profile_for_role;
@ -116,7 +122,6 @@ pub use agent_memory::repair_agent_memory_store;
pub use agent_memory::review_agent_memory_lifecycle_operation;
pub use agent_memory::review_agent_self_patch;
pub use agent_memory::review_agent_self_patch_document;
pub use agent_memory::swarm_identity_from_agent_memories;
pub use agent_memory::validate_agent_memory_store;
pub use agent_memory::write_agent_memory_entry_for_role;
pub use agent_utterance_state::AGENT_CHARACTER_STATE_VECTOR_SIZE;
@ -622,6 +627,18 @@ pub use memory_graph::MEMORY_GRAPH_TYPE;
pub use memory_graph::MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION;
pub use memory_graph::MemorySemanticIndexConfig;
pub use memory_graph::MemorySemanticIndexReceipt;
pub use memory_graph::MemorySemanticProjectionAttempt;
pub use memory_graph::MemorySemanticProjectionHealth;
pub use memory_graph::MemorySemanticProjectionHealthStatus;
pub use memory_graph::MemorySemanticProjectionObligation;
pub use memory_graph::MemorySemanticProjectionSourceHead;
pub use memory_graph::MEMORY_SEMANTIC_PROJECTION_ATTEMPT_SCHEMA_VERSION;
pub use memory_graph::MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION;
pub use memory_graph::bind_memory_semantic_index_receipt;
pub use memory_graph::derive_memory_semantic_projection_health;
pub use memory_graph::memory_semantic_projection_query_eligible;
pub use memory_graph::validate_memory_semantic_projection_attempt;
pub use memory_graph::validate_memory_semantic_projection_obligation;
pub use memory_graph::RepoFrontierAdoptedPlan;
pub use memory_graph::RepoFrontierItem;
pub use memory_graph::RepoFrontierStatus;

View file

@ -1,7 +1,7 @@
use super::{
EpiphanyMemoryContextPacket, EpiphanyMemoryContextQuery, EpiphanyMemoryGraphSnapshot,
SEMANTIC_PROJECTION_SCHEMA_VERSION, SemanticPartition, SemanticProjectionCandidate,
SemanticProjectionDocument, derive_semantic_projection, plan_memory_graph_context_cut,
SemanticProjectionDocument, derive_semantic_projection,
plan_memory_graph_context_cut_for_partition, resolve_semantic_candidate,
};
use crate::semantic_backend::{
@ -18,6 +18,10 @@ use std::path::Path;
pub const MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION: &str =
"gamecult.epiphany.memory_semantic_index_receipt.v0";
pub const MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION: &str =
"gamecult.epiphany.memory_semantic_projection_obligation.v0";
pub const MEMORY_SEMANTIC_PROJECTION_ATTEMPT_SCHEMA_VERSION: &str =
"gamecult.epiphany.memory_semantic_projection_attempt.v0";
const MODELING_COLLECTION_DEFAULT: &str = "epiphany_modeling_v1";
const MIND_COLLECTION_DEFAULT: &str = "epiphany_mind_v1";
const QUERY_LIMIT_MAX: usize = 64;
@ -100,6 +104,93 @@ struct MemorySemanticPointPayload {
embedding_model: String,
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "gamecult.epiphany.memory_semantic_projection_obligation",
schema = "MemorySemanticProjectionObligation"
)]
pub struct MemorySemanticProjectionObligation {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub obligation_id: String,
#[cultcache(key = 2)]
pub swarm_id: String,
#[cultcache(key = 3)]
pub partition: String,
#[cultcache(key = 4)]
pub canonical_source_id: String,
#[cultcache(key = 5)]
pub source_commit_id: String,
#[cultcache(key = 6)]
pub graph_id: String,
#[cultcache(key = 7)]
pub source_generation: u64,
#[cultcache(key = 8)]
pub source_model_hash: String,
#[cultcache(key = 9)]
pub canonical_content_set_hash: String,
#[cultcache(key = 10)]
pub projection_schema_version: String,
#[cultcache(key = 11)]
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "gamecult.epiphany.memory_semantic_projection_attempt",
schema = "MemorySemanticProjectionAttempt"
)]
pub struct MemorySemanticProjectionAttempt {
#[cultcache(key = 0)]
pub schema_version: String,
#[cultcache(key = 1)]
pub attempt_id: String,
#[cultcache(key = 2)]
pub obligation_id: String,
#[cultcache(key = 3)]
pub started_at: String,
#[cultcache(key = 4)]
pub completed_at: Option<String>,
#[cultcache(key = 5)]
pub status: String,
#[cultcache(key = 6)]
pub error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MemorySemanticProjectionHealthStatus {
Pending,
Failed,
Stale,
Ready,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MemorySemanticProjectionSourceHead {
pub swarm_id: String,
pub partition: String,
pub canonical_source_id: String,
pub source_commit_id: String,
pub graph_id: String,
pub source_generation: u64,
pub source_model_hash: String,
pub canonical_content_set_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MemorySemanticProjectionHealth {
pub status: MemorySemanticProjectionHealthStatus,
pub obligation_id: String,
pub receipt_id: Option<String>,
pub latest_attempt_id: Option<String>,
pub latest_error: Option<String>,
pub query_eligible: bool,
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "gamecult.epiphany.memory_semantic_index_receipt",
@ -138,6 +229,200 @@ pub struct MemorySemanticIndexReceipt {
pub indexed_at: String,
#[cultcache(key = 15)]
pub status: String,
#[cultcache(key = 16)]
pub obligation_id: String,
#[cultcache(key = 17)]
pub canonical_source_id: String,
#[cultcache(key = 18)]
pub source_commit_id: String,
#[cultcache(key = 19)]
pub source_generation: u64,
#[cultcache(key = 20)]
pub projection_schema_version: String,
}
pub fn validate_memory_semantic_projection_obligation(
obligation: &MemorySemanticProjectionObligation,
) -> Result<()> {
if obligation.schema_version != MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION {
return Err(anyhow!("unsupported semantic projection obligation schema"));
}
for (label, value) in [
("obligation_id", obligation.obligation_id.as_str()),
("swarm_id", obligation.swarm_id.as_str()),
("partition", obligation.partition.as_str()),
(
"canonical_source_id",
obligation.canonical_source_id.as_str(),
),
("source_commit_id", obligation.source_commit_id.as_str()),
("graph_id", obligation.graph_id.as_str()),
("source_model_hash", obligation.source_model_hash.as_str()),
(
"canonical_content_set_hash",
obligation.canonical_content_set_hash.as_str(),
),
("created_at", obligation.created_at.as_str()),
] {
if value.trim().is_empty() {
return Err(anyhow!("semantic projection obligation missing {label}"));
}
}
if !matches!(obligation.partition.as_str(), "mind" | "modeling") {
return Err(anyhow!(
"semantic projection obligation has invalid partition"
));
}
if obligation.projection_schema_version != SEMANTIC_PROJECTION_SCHEMA_VERSION {
return Err(anyhow!("semantic projection obligation schema mismatch"));
}
Ok(())
}
pub fn validate_memory_semantic_projection_attempt(
attempt: &MemorySemanticProjectionAttempt,
) -> Result<()> {
if attempt.schema_version != MEMORY_SEMANTIC_PROJECTION_ATTEMPT_SCHEMA_VERSION {
return Err(anyhow!("unsupported semantic projection attempt schema"));
}
if attempt.attempt_id.trim().is_empty()
|| attempt.obligation_id.trim().is_empty()
|| attempt.started_at.trim().is_empty()
{
return Err(anyhow!(
"semantic projection attempt is missing identity or time"
));
}
match attempt.status.as_str() {
"running" if attempt.completed_at.is_none() && attempt.error.is_none() => Ok(()),
"failed" if attempt.completed_at.is_some() && attempt.error.is_some() => Ok(()),
_ => Err(anyhow!(
"semantic projection attempt status does not match its terminal fields"
)),
}
}
pub fn derive_memory_semantic_projection_health(
obligation: &MemorySemanticProjectionObligation,
current: &MemorySemanticProjectionSourceHead,
attempts: &[MemorySemanticProjectionAttempt],
receipts: &[MemorySemanticIndexReceipt],
) -> Result<MemorySemanticProjectionHealth> {
validate_memory_semantic_projection_obligation(obligation)?;
for attempt in attempts
.iter()
.filter(|attempt| attempt.obligation_id == obligation.obligation_id)
{
validate_memory_semantic_projection_attempt(attempt)?;
}
let stale = !obligation_matches_source(obligation, current);
let mut matching_receipts = receipts
.iter()
.filter(|receipt| receipt_matches_obligation(receipt, obligation))
.collect::<Vec<_>>();
matching_receipts.sort_by(|left, right| left.indexed_at.cmp(&right.indexed_at));
let receipt = matching_receipts.last().copied();
let mut matching_attempts = attempts
.iter()
.filter(|attempt| attempt.obligation_id == obligation.obligation_id)
.collect::<Vec<_>>();
matching_attempts.sort_by(|left, right| {
left.completed_at
.as_deref()
.unwrap_or(left.started_at.as_str())
.cmp(
right
.completed_at
.as_deref()
.unwrap_or(right.started_at.as_str()),
)
});
let latest_attempt = matching_attempts.last().copied();
let status = if stale {
MemorySemanticProjectionHealthStatus::Stale
} else if receipt.is_some() {
MemorySemanticProjectionHealthStatus::Ready
} else if latest_attempt.is_some_and(|attempt| attempt.status == "failed") {
MemorySemanticProjectionHealthStatus::Failed
} else {
MemorySemanticProjectionHealthStatus::Pending
};
Ok(MemorySemanticProjectionHealth {
status,
obligation_id: obligation.obligation_id.clone(),
receipt_id: receipt.map(|receipt| receipt.receipt_id.clone()),
latest_attempt_id: latest_attempt.map(|attempt| attempt.attempt_id.clone()),
latest_error: latest_attempt.and_then(|attempt| attempt.error.clone()),
query_eligible: status == MemorySemanticProjectionHealthStatus::Ready,
})
}
pub fn memory_semantic_projection_query_eligible(
obligation: &MemorySemanticProjectionObligation,
current: &MemorySemanticProjectionSourceHead,
receipt: &MemorySemanticIndexReceipt,
) -> bool {
validate_memory_semantic_projection_obligation(obligation).is_ok()
&& obligation_matches_source(obligation, current)
&& receipt_matches_obligation(receipt, obligation)
}
pub fn bind_memory_semantic_index_receipt(
mut receipt: MemorySemanticIndexReceipt,
obligation: &MemorySemanticProjectionObligation,
) -> Result<MemorySemanticIndexReceipt> {
validate_memory_semantic_projection_obligation(obligation)?;
if receipt.swarm_id != obligation.swarm_id
|| receipt.partition != obligation.partition
|| receipt.graph_id != obligation.graph_id
|| receipt.model_revision != obligation.source_generation
|| receipt.model_hash != obligation.source_model_hash
|| receipt.canonical_content_set_hash != obligation.canonical_content_set_hash
|| receipt.status != "ready"
{
return Err(anyhow!(
"semantic index result does not match the exact projection obligation"
));
}
receipt.obligation_id = obligation.obligation_id.clone();
receipt.canonical_source_id = obligation.canonical_source_id.clone();
receipt.source_commit_id = obligation.source_commit_id.clone();
receipt.source_generation = obligation.source_generation;
receipt.projection_schema_version = obligation.projection_schema_version.clone();
Ok(receipt)
}
fn obligation_matches_source(
obligation: &MemorySemanticProjectionObligation,
current: &MemorySemanticProjectionSourceHead,
) -> bool {
obligation.swarm_id == current.swarm_id
&& obligation.partition == current.partition
&& obligation.canonical_source_id == current.canonical_source_id
&& obligation.source_commit_id == current.source_commit_id
&& obligation.graph_id == current.graph_id
&& obligation.source_generation == current.source_generation
&& obligation.source_model_hash == current.source_model_hash
&& obligation.canonical_content_set_hash == current.canonical_content_set_hash
}
fn receipt_matches_obligation(
receipt: &MemorySemanticIndexReceipt,
obligation: &MemorySemanticProjectionObligation,
) -> bool {
receipt.schema_version == MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION
&& receipt.status == "ready"
&& receipt.obligation_id == obligation.obligation_id
&& receipt.swarm_id == obligation.swarm_id
&& receipt.partition == obligation.partition
&& receipt.canonical_source_id == obligation.canonical_source_id
&& receipt.source_commit_id == obligation.source_commit_id
&& receipt.graph_id == obligation.graph_id
&& receipt.source_generation == obligation.source_generation
&& receipt.model_revision == obligation.source_generation
&& receipt.model_hash == obligation.source_model_hash
&& receipt.canonical_content_set_hash == obligation.canonical_content_set_hash
&& receipt.projection_schema_version == obligation.projection_schema_version
}
pub fn index_memory_semantic_partition(
@ -230,6 +515,11 @@ pub fn index_memory_semantic_partition(
canonical_content_set_hash: content_set_hash,
indexed_at: indexed_at.to_string(),
status: "ready".to_string(),
obligation_id: String::new(),
canonical_source_id: snapshot.graph_id.clone(),
source_commit_id: String::new(),
source_generation: snapshot.model_revision,
projection_schema_version: SEMANTIC_PROJECTION_SCHEMA_VERSION.to_string(),
})
}
@ -255,7 +545,8 @@ pub fn semantic_memory_context(
match try_semantic_memory_context(snapshot, swarm_id, partition, query, config) {
Ok(packet) => packet,
Err(error) => {
let mut packet = plan_memory_graph_context_cut(snapshot, query);
let mut packet =
plan_memory_graph_context_cut_for_partition(snapshot, query, &[], partition);
packet.warnings.push(format!(
"semantic projection unavailable; used canonical BM25 fallback: {error}"
));
@ -479,7 +770,12 @@ mod tests {
text: Some("canonical qdrant semantic".to_string()),
..Default::default()
};
let expected = plan_memory_graph_context_cut(&snapshot, &query);
let expected = plan_memory_graph_context_cut_for_partition(
&snapshot,
&query,
&[],
SemanticPartition::Modeling,
);
let mut config = MemorySemanticIndexConfig::from_env();
config.qdrant_url = "http://127.0.0.1:1".to_string();
config.qdrant_timeout_ms = 25;
@ -513,4 +809,182 @@ mod tests {
config.query_instruction(SemanticPartition::Modeling)
);
}
fn obligation() -> MemorySemanticProjectionObligation {
MemorySemanticProjectionObligation {
schema_version: MEMORY_SEMANTIC_PROJECTION_OBLIGATION_SCHEMA_VERSION.to_string(),
obligation_id: "projection:swarm:modeling:commit-7".to_string(),
swarm_id: "swarm".to_string(),
partition: "modeling".to_string(),
canonical_source_id: "runtime-spine".to_string(),
source_commit_id: "commit-7".to_string(),
graph_id: "graph".to_string(),
source_generation: 7,
source_model_hash: "model-hash".to_string(),
canonical_content_set_hash: "content-hash".to_string(),
projection_schema_version: SEMANTIC_PROJECTION_SCHEMA_VERSION.to_string(),
created_at: "2026-07-15T10:00:00Z".to_string(),
}
}
fn source_head() -> MemorySemanticProjectionSourceHead {
let obligation = obligation();
MemorySemanticProjectionSourceHead {
swarm_id: obligation.swarm_id,
partition: obligation.partition,
canonical_source_id: obligation.canonical_source_id,
source_commit_id: obligation.source_commit_id,
graph_id: obligation.graph_id,
source_generation: obligation.source_generation,
source_model_hash: obligation.source_model_hash,
canonical_content_set_hash: obligation.canonical_content_set_hash,
}
}
fn receipt() -> MemorySemanticIndexReceipt {
let obligation = obligation();
MemorySemanticIndexReceipt {
schema_version: MEMORY_SEMANTIC_INDEX_RECEIPT_SCHEMA_VERSION.to_string(),
receipt_id: "receipt-7".to_string(),
swarm_id: obligation.swarm_id,
partition: obligation.partition,
collection_name: "epiphany_modeling_v1".to_string(),
graph_id: obligation.graph_id,
model_revision: obligation.source_generation,
model_hash: obligation.source_model_hash,
embedding_provider_id: "provider".to_string(),
embedding_model: "model".to_string(),
vector_dimensions: 1024,
indexed_document_count: 3,
deleted_document_count: 0,
canonical_content_set_hash: obligation.canonical_content_set_hash,
indexed_at: "2026-07-15T10:01:00Z".to_string(),
status: "ready".to_string(),
obligation_id: obligation.obligation_id,
canonical_source_id: obligation.canonical_source_id,
source_commit_id: obligation.source_commit_id,
source_generation: obligation.source_generation,
projection_schema_version: obligation.projection_schema_version,
}
}
fn failed_attempt() -> MemorySemanticProjectionAttempt {
MemorySemanticProjectionAttempt {
schema_version: MEMORY_SEMANTIC_PROJECTION_ATTEMPT_SCHEMA_VERSION.to_string(),
attempt_id: "attempt-1".to_string(),
obligation_id: obligation().obligation_id,
started_at: "2026-07-15T10:00:30Z".to_string(),
completed_at: Some("2026-07-15T10:00:31Z".to_string()),
status: "failed".to_string(),
error: Some("qdrant unavailable".to_string()),
}
}
#[test]
fn projection_health_is_derived_from_exact_canonical_causality() {
let obligation = obligation();
let current = source_head();
let pending =
derive_memory_semantic_projection_health(&obligation, &current, &[], &[]).unwrap();
assert_eq!(
pending.status,
MemorySemanticProjectionHealthStatus::Pending
);
assert!(!pending.query_eligible);
let failed = derive_memory_semantic_projection_health(
&obligation,
&current,
&[failed_attempt()],
&[],
)
.unwrap();
assert_eq!(failed.status, MemorySemanticProjectionHealthStatus::Failed);
assert_eq!(failed.latest_error.as_deref(), Some("qdrant unavailable"));
let ready = derive_memory_semantic_projection_health(
&obligation,
&current,
&[failed_attempt()],
&[receipt()],
)
.unwrap();
assert_eq!(ready.status, MemorySemanticProjectionHealthStatus::Ready);
assert!(ready.query_eligible);
let mut newer = current.clone();
newer.source_generation += 1;
newer.source_commit_id = "commit-8".to_string();
let stale = derive_memory_semantic_projection_health(
&obligation,
&newer,
&[failed_attempt()],
&[receipt()],
)
.unwrap();
assert_eq!(stale.status, MemorySemanticProjectionHealthStatus::Stale);
assert!(!stale.query_eligible);
}
#[test]
fn hostile_receipt_mismatches_never_grant_query_eligibility() {
let obligation = obligation();
let current = source_head();
for field in [
"obligation",
"swarm",
"partition",
"source",
"commit",
"graph",
"generation",
"revision",
"model",
"content",
"projection-schema",
"status",
] {
let mut hostile = receipt();
match field {
"obligation" => hostile.obligation_id = "other".to_string(),
"swarm" => hostile.swarm_id = "other".to_string(),
"partition" => hostile.partition = "mind".to_string(),
"source" => hostile.canonical_source_id = "other".to_string(),
"commit" => hostile.source_commit_id = "other".to_string(),
"graph" => hostile.graph_id = "other".to_string(),
"generation" => hostile.source_generation += 1,
"revision" => hostile.model_revision += 1,
"model" => hostile.model_hash = "other".to_string(),
"content" => hostile.canonical_content_set_hash = "other".to_string(),
"projection-schema" => hostile.projection_schema_version = "other".to_string(),
"status" => hostile.status = "failed".to_string(),
_ => unreachable!(),
}
assert!(
!memory_semantic_projection_query_eligible(&obligation, &current, &hostile),
"hostile {field} substitution was accepted"
);
}
}
#[test]
fn unbound_index_result_is_not_ready_until_exact_obligation_binding() {
let obligation = obligation();
let current = source_head();
let mut result = receipt();
result.obligation_id.clear();
result.canonical_source_id.clear();
result.source_commit_id.clear();
assert!(!memory_semantic_projection_query_eligible(
&obligation,
&current,
&result
));
let bound = bind_memory_semantic_index_receipt(result, &obligation).unwrap();
assert!(memory_semantic_projection_query_eligible(
&obligation,
&current,
&bound
));
}
}

View file

@ -1069,3 +1069,23 @@ Next: schedule projection refresh as completion-gated physiology, publish typed
projection health through CultMesh/Eve, and refresh the stale source anchors
that leave the current Body map honestly thin. Do not let the scheduler,
Qdrant, Yggdrasil, or presentation become state authority.
## Semantic completion-law correction (2026-07-15)
The first scheduling map exposed two surviving substitutions before daemon
work began. Persona recall derived a swarm namespace from fixed organ agent
ids, which are identical across Epiphany swarms; and Qdrant failure still used
the unconstrained canonical planner. Both are cut. `state/agents.msgpack` now
contains the immutable typed swarm identity `gamecult.epiphany.main`; Persona
recall and the semantic CLI require that exact document, and the Mind
collection was rebuilt under that scope with 43 documents. Canonical fallback
uses the same partition-constrained planner as semantic success.
`memory_graph::semantic_index` now defines typed projection obligation and
attempt documents, exact source-head and receipt causality fields, and pure
derived `pending|failed|stale|ready` health/query-eligibility law with hostile
mismatch tests. These types are foundation, not a readiness claim: runtime
admission does not yet atomically emit obligations, indexing does not yet claim
and discharge them, queries do not yet require the newest exact success, and
CultMesh/Idunn wiring deliberately remains absent. The next cut is canonical
admission ownership, not a polling loop.

View file

@ -180,3 +180,71 @@ Local proof indexed 43 Mind documents and 3 currently fresh Modeling documents
with `qwen3-embedding:0.6b` at 1024 dimensions. The thin Modeling count is
honest pressure: several imported thread-state claims have missing or changed
source anchors and remain stale rather than being made searchable by optimism.
## Completion-Gated Projection Physiology
The landed projection has an indexing command and immutable index receipts, but
no durable reflex from canonical admission to projection completion. Calling
the command from a timer would make elapsed time a substitute for causality.
### Owner And Inputs
- The canonical admission transaction owns projection pressure. A RepoModel
admission already commits a `RepoModelAdmissionReceipt` with the admitted
revision and hash. An agent-Mind mutation must likewise commit the exact
`EpiphanyAgentMemoryEntry` generation and a typed Mind commit witness in one
CAS; the current direct `cache.put` writers in `agent_memory.rs` are not yet a
sufficient completion boundary.
- That transaction emits one immutable semantic-projection obligation as a
companion, keyed by swarm, partition, canonical source identity, and exact
canonical generation/content-set hash. The obligation carries no claim text
and grants no memory authority.
- The projector reads only the obligation plus the canonical CultCache source,
derives projection documents again, and refuses any generation mismatch.
### Completion Signal And Outputs
- `MemorySemanticIndexReceipt` is the completion signal only when it binds the
exact obligation, canonical source generation/content-set hash, collection
compatibility, indexed count, and terminal status. Its present shape lacks
the obligation/source-commit reference and therefore proves an indexing run,
not discharge of admission pressure.
- Projection health is derived: `ready` means the newest canonical obligation
has one exact successful receipt; `pending` means it has none; `failed` means
the latest attempt receipt failed while the obligation remains open; `stale`
means a newer canonical generation exists. Counts, timestamps, vectors, and
retry attempts remain notification/cache state.
- Semantic query correctness never waits for health. A missing or non-ready
receipt selects canonical BM25; a matching receipt permits Qdrant ranking
followed by canonical candidate revalidation.
### Nervous System, Idunn, And CultMesh
- The admission companion is the reflex. A scheduler may immediately queue it;
a periodic pulse may rediscover an uncompleted obligation after restart.
Neither timer nor heartbeat may invent an obligation, declare it complete,
advance its canonical generation, or suppress BM25 fallback.
- Idunn owns survival, restart, executable identity, and aftercare for the
projector daemon. It may report that the worker is unavailable or restart
it; it does not own canonical memory, projection demand, indexed truth, or
completion judgment.
- CultMesh carries typed obligation, attempt/receipt, and derived-health
projections for operators and consumers. It does not host a second queue or
writable health flag. Provider-owned CultCache documents remain the source;
CultMesh publication is a lowering of those documents, and Eve/Gjallar only
render them downstream.
### Forbidden Writers, Shared Paths, And Cut Line
- Forbidden completion writers: wall-clock timers, collection existence,
Qdrant point counts, payload prose, Idunn liveness, CultMesh mirrors, Eve or
Gjallar, and manual status commands.
- RepoModel repair/evolution/verdict admission and every reviewed agent-Mind
mutation must share the same `canonical commit + projection obligation`
primitive. Bootstrap/import may use it once; raw `cache.put` paths may not
bypass it.
- Cut first: replace direct agent-memory writes with an atomic admitted
generation/commit witness; extend the index receipt to bind an obligation;
then add the Idunn-hosted consumer and CultMesh health projection. Do not add
polling flags, file-mtime inference, or a reconciliation cache around the
current unwitnessed writes.

Binary file not shown.

Binary file not shown.

View file

@ -714,4 +714,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: Operationalize the landed shared Mind/Modeling semantic nerve as a daemon/CultMesh capability and refresh the stale source anchors that leave the current Modeling projection honestly thin. The first production path is live: one typed Qdrant/Ollama backend, physical `epiphany_mind_v1`/`epiphany_modeling_v1` partitions, stable locator-only points, strict collection compatibility, scope-bounded sync, canonical candidate reload, typed CultCache projection receipts outside RepoModel, BM25 fallback, explicit RepoModel bootstrap, and Persona heartbeat recall through the shared Mind graph. Local live proof indexed 43 Mind documents and 3 fresh Modeling documents at 1024 dimensions with `qwen3-embedding:0.6b`; stale Modeling descendants are excluded. The old Persona-only client/chunk/index/search authority, live collection, tracked v0 sibling memory graph, and four surviving Proprioception phrases in Modeling Mind state are purged. Next add completion-gated projection scheduling, typed health/readback through CultMesh/Eve, and source-grounded Modeling refresh without letting Qdrant, Yggdrasil, presentation, or thread-state become truth. The repo-frontier planning nerve remains complete. Do not revive Proprioception, epiphany-work, metadata adapters, generic work-order routers, shadow candidate/adoption stores, embodied Mind lanes, Qdrant payload prose, or inference from namespaces/prompt prose/evidence coincidence. Keep closure's zero-substring invariant.
next_action: Wire completion-gated semantic projection into canonical admission before adding its daemon. Swarm identity is now an explicit immutable typed document in the Mind store (`gamecult.epiphany.main` locally); fixed organ agent ids are no longer misused as swarm identity. Pure typed projection obligations, attempts, exact receipt causality, and derived pending/failed/stale/ready health law exist, but are not yet runtime authority. Next make RepoModel bootstrap/admission/frontier adoption and every reviewed Mind mutation atomically commit canonical generation plus one obligation; cut remaining raw Mind writers or demote them to migration-only. Then make query eligibility require the newest exact obligation/success receipt, add restart-safe claim/attempt execution, and only afterward publish provider-owned CultMesh sight and attach Idunn survival policy. Canonical BM25 remains partition-constrained and correct throughout. Refresh stale Modeling anchors after the completion nerve is causal. Do not let timers, Qdrant compatibility, point counts, Idunn liveness, CultMesh mirrors, Eve/Gjallar, fixed agent ids, or payload prose impersonate projection readiness or swarm identity.