From e5554e60b2561059166f4ddcd89420899ca3aa3c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 14 Aug 2026 12:07:34 +0200 Subject: [PATCH] Complete keyed RepoModel mutation planning --- epiphany-core/src/repo_model_documents.rs | 286 +++++++++++++++++++--- 1 file changed, 251 insertions(+), 35 deletions(-) diff --git a/epiphany-core/src/repo_model_documents.rs b/epiphany-core/src/repo_model_documents.rs index 3beec029..c6aa3cbf 100644 --- a/epiphany-core/src/repo_model_documents.rs +++ b/epiphany-core/src/repo_model_documents.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use cultcache_rs::{CultCache, CultCacheEnvelope, DatabaseEntry}; use epiphany_state_model::{ EpiphanyMemoryDomain, EpiphanyMemoryEdge, EpiphanyMemoryGraphSnapshot, @@ -10,7 +10,7 @@ use epiphany_state_model::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::{EpiphanyMindDocumentVersion, runtime_spine_cache, validate_memory_graph_snapshot}; +use crate::{runtime_spine_cache, validate_memory_graph_snapshot, EpiphanyMindDocumentVersion}; pub const REPO_MODEL_SCHEMA_EPOCH: &str = "epiphany.repo_model.epoch.v1"; pub const REPO_MODEL_IDENTITY_KEY: &str = "repo-model"; @@ -128,8 +128,11 @@ pub const REPO_MODEL_MUTATION_PROPOSAL_SCHEMA_VERSION: &str = #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] pub enum EpiphanyRepoModelMutationOperation { + PutDomain { domain: EpiphanyMemoryDomain }, PutNode { node: EpiphanyMemoryNode }, RetireNode { node_id: String }, + PutEdge { edge: EpiphanyMemoryEdge }, + PutSummary { summary: EpiphanyMemorySummary }, PutFrontier { item: RepoFrontierItem }, } @@ -270,7 +273,7 @@ pub fn plan_repo_model_mutation( let identity_envelope = cache .get_envelope::(REPO_MODEL_IDENTITY_KEY)? .ok_or_else(|| anyhow!("RepoModel mutation lost its identity envelope"))?; - let domains = view + let mut domains = view .domains .iter() .cloned() @@ -282,6 +285,18 @@ pub fn plan_repo_model_mutation( .cloned() .map(|value| (value.id.clone(), value)) .collect::>(); + let mut edges = view + .edges + .iter() + .cloned() + .map(|value| (value.id.clone(), value)) + .collect::>(); + let mut summaries = view + .summaries + .iter() + .cloned() + .map(|value| (value.id.clone(), value)) + .collect::>(); let mut frontier = view .frontier .iter() @@ -299,7 +314,14 @@ pub fn plan_repo_model_mutation( .filter_map(|operation| match operation { EpiphanyRepoModelMutationOperation::PutNode { node } => Some(node.id.clone()), EpiphanyRepoModelMutationOperation::RetireNode { node_id } => Some(node_id.clone()), - EpiphanyRepoModelMutationOperation::PutFrontier { .. } => None, + _ => None, + }) + .collect::>(); + let staged_domain_ids = operations + .iter() + .filter_map(|operation| match operation { + EpiphanyRepoModelMutationOperation::PutDomain { domain } => Some(domain.id.clone()), + _ => None, }) .collect::>(); let staged_frontier_ids = operations @@ -309,28 +331,18 @@ pub fn plan_repo_model_mutation( _ => None, }) .collect::>(); - if staged_node_ids.len() - != operations - .iter() - .filter(|operation| { - matches!( - operation, - EpiphanyRepoModelMutationOperation::PutNode { .. } - | EpiphanyRepoModelMutationOperation::RetireNode { .. } - ) - }) - .count() - || staged_frontier_ids.len() - != operations - .iter() - .filter(|operation| { - matches!( - operation, - EpiphanyRepoModelMutationOperation::PutFrontier { .. } - ) - }) - .count() - { + let staged_edge_ids = operations + .iter() + .filter_map(|operation| match operation { + EpiphanyRepoModelMutationOperation::PutEdge { edge } => Some(edge.id.clone()), + _ => None, + }) + .collect::>(); + let operation_identities = operations + .iter() + .map(repo_model_operation_identity) + .collect::>>()?; + if operation_identities.len() != operations.len() { return Err(anyhow!( "RepoModel mutation proposal repeats a semantic identity" )); @@ -346,13 +358,30 @@ pub fn plan_repo_model_mutation( let mut writes = BTreeMap::new(); for operation in &operations { match operation { + EpiphanyRepoModelMutationOperation::PutDomain { domain } => { + require_semantic_id(&domain.id, "RepoModel domain")?; + if let Some(existing) = + cache.get_envelope::(&domain.id)? + { + insert_strong_envelope(&mut strong, existing)?; + } + domains.insert(domain.id.clone(), domain.clone()); + insert_envelope( + &mut writes, + cache + .prepare_entry(&domain.id, &EpiphanyRepoModelDomainDocument::new(domain)?)? + .0, + )?; + } EpiphanyRepoModelMutationOperation::PutNode { node } => { require_semantic_id(&node.id, "RepoModel node")?; - require_dependency_envelope::( - &cache, - &node.domain_id, - &mut strong, - )?; + if !staged_domain_ids.contains(&node.domain_id) { + require_dependency_envelope::( + &cache, + &node.domain_id, + &mut strong, + )?; + } if let Some(existing) = cache.get_envelope::(&node.id)? { @@ -400,6 +429,73 @@ pub fn plan_repo_model_mutation( )?; insert_envelope(&mut writes, cache.prepare_entry(node_id, obligation)?.0)?; } + EpiphanyRepoModelMutationOperation::PutEdge { edge } => { + require_semantic_id(&edge.id, "RepoModel edge")?; + for node_id in [&edge.source_id, &edge.target_id] { + if !staged_node_ids.contains(node_id) { + require_dependency_envelope::( + &cache, + node_id, + &mut strong, + )?; + } + } + if let Some(existing) = + cache.get_envelope::(&edge.id)? + { + insert_strong_envelope(&mut strong, existing)?; + } + edges.insert(edge.id.clone(), edge.clone()); + insert_envelope( + &mut writes, + cache + .prepare_entry(&edge.id, &EpiphanyRepoModelEdgeDocument::new(edge)?)? + .0, + )?; + } + EpiphanyRepoModelMutationOperation::PutSummary { summary } => { + require_semantic_id(&summary.id, "RepoModel summary")?; + if !staged_domain_ids.contains(&summary.domain_id) { + require_dependency_envelope::( + &cache, + &summary.domain_id, + &mut strong, + )?; + } + for node_id in &summary.covers_node_ids { + if !staged_node_ids.contains(node_id) { + require_dependency_envelope::( + &cache, + node_id, + &mut strong, + )?; + } + } + for edge_id in &summary.covers_edge_ids { + if !staged_edge_ids.contains(edge_id) { + require_dependency_envelope::( + &cache, + edge_id, + &mut strong, + )?; + } + } + if let Some(existing) = + cache.get_envelope::(&summary.id)? + { + insert_strong_envelope(&mut strong, existing)?; + } + summaries.insert(summary.id.clone(), summary.clone()); + insert_envelope( + &mut writes, + cache + .prepare_entry( + &summary.id, + &EpiphanyRepoModelSummaryDocument::new(summary)?, + )? + .0, + )?; + } EpiphanyRepoModelMutationOperation::PutFrontier { item } => { require_semantic_id(&item.id, "RepoModel frontier")?; let prior = frontier.get(&item.id).cloned(); @@ -473,8 +569,8 @@ pub fn plan_repo_model_mutation( &view.identity, domains.values().cloned().collect(), nodes.values().cloned().collect(), - view.edges, - view.summaries, + edges.values().cloned().collect(), + summaries.values().cloned().collect(), frontier.values().cloned().collect(), view.lifecycle_receipts, obligations.values().cloned().collect(), @@ -486,6 +582,21 @@ pub fn plan_repo_model_mutation( }) } +fn repo_model_operation_identity( + operation: &EpiphanyRepoModelMutationOperation, +) -> Result<(String, String)> { + let (kind, id) = match operation { + EpiphanyRepoModelMutationOperation::PutDomain { domain } => ("domain", &domain.id), + EpiphanyRepoModelMutationOperation::PutNode { node } => ("node", &node.id), + EpiphanyRepoModelMutationOperation::RetireNode { node_id } => ("node", node_id), + EpiphanyRepoModelMutationOperation::PutEdge { edge } => ("edge", &edge.id), + EpiphanyRepoModelMutationOperation::PutSummary { summary } => ("summary", &summary.id), + EpiphanyRepoModelMutationOperation::PutFrontier { item } => ("frontier", &item.id), + }; + require_semantic_id(id, "RepoModel operation")?; + Ok((kind.into(), id.clone())) +} + fn insert_envelope( target: &mut BTreeMap<(String, String), CultCacheEnvelope>, envelope: CultCacheEnvelope, @@ -696,9 +807,10 @@ fn validate_claim_obligations( #[cfg(test)] mod tests { use super::*; - use crate::{RuntimeSpineInitOptions, initialize_runtime_spine}; + use crate::{initialize_runtime_spine, RuntimeSpineInitOptions}; use epiphany_state_model::{ - EpiphanyMemoryLifecycle, EpiphanyMemoryNodeKind, EpiphanyMemoryProfile, RepoFrontierStatus, + EpiphanyMemoryEdgeKind, EpiphanyMemoryLifecycle, EpiphanyMemoryNodeKind, + EpiphanyMemoryProfile, RepoFrontierStatus, }; #[test] @@ -816,4 +928,108 @@ mod tests { assert_eq!(after.nodes[0].lifecycle, EpiphanyMemoryLifecycle::Accepted); Ok(()) } + + #[test] + fn one_mutation_can_create_a_complete_keyed_graph_slice_in_any_operation_order() -> Result<()> { + let temp = tempfile::tempdir()?; + let store = temp.path().join("repo-model.cc"); + initialize_runtime_spine( + &store, + RuntimeSpineInitOptions { + runtime_id: "repo-model-atomic-slice".into(), + display_name: "RepoModel atomic slice".into(), + created_at: "2026-08-14T00:00:00Z".into(), + }, + )?; + let mut cache = runtime_spine_cache(&store)?; + cache.put( + REPO_MODEL_IDENTITY_KEY, + &EpiphanyRepoModelIdentityDocument { + schema_epoch: REPO_MODEL_SCHEMA_EPOCH.into(), + graph_id: "graph-atomic".into(), + runtime_id: "repo-model-atomic-slice".into(), + swarm_id: "swarm-1".into(), + workspace_id: "workspace-1".into(), + body_binding_sha256: "sha256:body".into(), + }, + )?; + let domain = EpiphanyMemoryDomain { + id: "domain-new".into(), + profile: EpiphanyMemoryProfile::RepoArchitecture, + title: "New domain".into(), + lifecycle: EpiphanyMemoryLifecycle::Accepted, + ..Default::default() + }; + let node = |id: &str, claim: &str| EpiphanyMemoryNode { + id: id.into(), + domain_id: domain.id.clone(), + profile: EpiphanyMemoryProfile::RepoArchitecture, + kind: EpiphanyMemoryNodeKind::Module, + title: id.into(), + claim: claim.into(), + question: "What does this own?".into(), + action_implication: "Keep the owner explicit".into(), + source_hashes: vec!["anchor:missing".into()], + lifecycle: EpiphanyMemoryLifecycle::Accepted, + ..Default::default() + }; + let left = node("node-left", "Left owns input"); + let right = node("node-right", "Right owns output"); + let edge = EpiphanyMemoryEdge { + id: "edge-flow".into(), + source_id: left.id.clone(), + target_id: right.id.clone(), + kind: EpiphanyMemoryEdgeKind::Writes, + profile: EpiphanyMemoryProfile::RepoArchitecture, + claim: "Left writes Right".into(), + lifecycle: EpiphanyMemoryLifecycle::Accepted, + ..Default::default() + }; + let summary = EpiphanyMemorySummary { + id: "summary-flow".into(), + domain_id: domain.id.clone(), + covers_node_ids: vec![left.id.clone(), right.id.clone()], + covers_edge_ids: vec![edge.id.clone()], + target: "flow".into(), + claim: "The flow is document-addressed".into(), + question: "Can its members commit atomically?".into(), + action_implication: "Commit the exact keyed slice".into(), + ..Default::default() + }; + let proposal = EpiphanyRepoModelMutationProposal::new( + "proposal-atomic-slice", + vec![ + EpiphanyRepoModelMutationOperation::PutSummary { + summary: summary.clone(), + }, + EpiphanyRepoModelMutationOperation::PutEdge { edge: edge.clone() }, + EpiphanyRepoModelMutationOperation::PutNode { + node: right.clone(), + }, + EpiphanyRepoModelMutationOperation::PutNode { node: left.clone() }, + EpiphanyRepoModelMutationOperation::PutDomain { + domain: domain.clone(), + }, + ], + )?; + let plan = plan_repo_model_mutation(&store, &proposal)?; + let provenance = cache.prepare_entry(&proposal.proposal_id, &proposal)?.0; + assert!(matches!( + crate::commit_operator_mind_mutation( + &store, + provenance, + "Modeling.repo_model_mutation", + plan.strong_reads, + plan.writes, + "2026-08-14T00:00:01Z", + )?, + crate::EpiphanyMindCommitOutcome::Committed(_) + )); + let view = assemble_repo_model_view(&store)?; + assert_eq!(view.domains, [domain]); + assert_eq!(view.nodes, [left, right]); + assert_eq!(view.edges, [edge]); + assert_eq!(view.summaries, [summary]); + Ok(()) + } }