Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/architecture/data/sync-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,64 @@ class OperationalTransformer {

## Vector Embedding Synchronization

### Embedding Sync Boundaries — Local Commit Before Sync

**Core principle: Sync operates on committed state, not in-progress state.**

A node is not considered "committed" until all its derived data (including embeddings)
has been computed. This mirrors database transaction semantics — partial writes are
never exposed to other readers. The sync outbox only contains fully resolved nodes.

**Rules:**

1. **A node does not enter the sync outbox until its embedding is complete.** When
content changes, the node is marked stale locally and held back from sync until
re-embedding finishes. The sync layer never sees intermediate state.

2. **Stale markers are local-only.** They drive the local embedding pipeline and are
never transmitted to other clients. No client should ever receive a node in a
stale/pending state.

3. **Each synced node is an atomic unit:** content + embedding vector + content hash
(`sha256(content)`) + model ID. Receiving clients can trust that the embedding
matches the content.

```
Sync outbox eligibility:
✅ Root node with completed embedding (content_hash covers root + all children)
→ entire subtree (root + all descendants) syncs as one atomic unit
❌ Root node with stale/pending embedding → entire subtree held back
❌ Stale markers (never synced)
```

**Subtree as atomic sync unit**: Embeddings are computed only at the root node
level, incorporating the content of all descendant nodes. Therefore the sync
unit is the **entire subtree** — root node + all children. If a child node's
content changes, the root's embedding becomes stale, and the whole subtree is
held from the sync outbox until re-embedding completes. When it does, the root
and all its descendants sync together as one atomic operation.

**Edge cases:**

- **Database switch before re-embedding completes**: The pending nodes remain in the
local database but never enter the sync outbox. If the database is never opened
again, those nodes simply don't sync — which is correct, since no client is
actively using that database.

- **Embedding model failure**: If embeddings cannot be computed (GPU error, model
won't load), affected nodes are held from sync. This is surfaced as a user-visible
error. The sync layer does not attempt to work around broken local state.

- **Node update with pending re-embedding**: When an existing node's content changes,
the sync outbox retains the last committed version (previous content + its matching
embedding) until the new embedding completes. Once re-embedding finishes, the update
becomes the new committed state and replaces the previous version in the outbox.
This is analogous to offline edits — the user keeps working locally, but the sync
layer only transmits fully resolved state.

- **Receiving a synced node**: Because only committed nodes are synced, the receiver
accepts the node + embedding as-is. No receiver-side audit or re-embedding needed.

### Efficient Vector Sync
```rust
pub struct VectorSyncOptimizer {
Expand Down
20 changes: 14 additions & 6 deletions packages/core/src/mcp/handlers/initialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use std::sync::Arc;
/// - Embedding model warmup fails
pub async fn handle_initialize<C>(
node_service: &Arc<NodeService<C>>,
embedding_service: &Arc<NodeEmbeddingService<C>>,
embedding_service: &Option<Arc<NodeEmbeddingService<C>>>,
params: Value,
) -> Result<Value, MCPError>
where
Expand Down Expand Up @@ -75,12 +75,20 @@ where
)));
}

// Warm up the embedding model to ensure fast first semantic search
// Warm up the embedding model to ensure fast first semantic search (if available).
// This triggers model loading and Metal kernel compilation during handshake
// rather than on the first search query.
embedding_service.nlp_engine().warmup().map_err(|e| {
MCPError::internal_error(format!("Failed to warm up embedding model: {}", e))
})?;
// rather than on the first search query. When embeddings are unavailable,
// MCP still serves node CRUD — only semantic search is disabled.
if let Some(emb_svc) = embedding_service {
if let Err(e) = emb_svc.nlp_engine().warmup() {
tracing::warn!(
"Embedding model warmup failed (semantic search disabled): {}",
e
);
}
} else {
tracing::info!("MCP server starting without embeddings — semantic search disabled");
}

// Fetch all available schemas to build dynamic instructions
// Note: If schemas haven't been initialized yet (fresh database), fall back to
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/mcp/handlers/initialize_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::sync::Arc;
use tempfile::TempDir;

// Helper to create test services (NodeService + NodeEmbeddingService)
async fn create_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir) {
async fn create_test_services() -> (Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir) {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");
let mut store = Arc::new(SurrealStore::new(db_path).await.unwrap());
Expand All @@ -22,7 +22,7 @@ async fn create_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>,

let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));

(node_service, embedding_service, temp_dir)
(node_service, Some(embedding_service), temp_dir)
}

#[tokio::test]
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/mcp/handlers/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ pub fn handle_tools_list(_params: Value) -> Result<Value, MCPError> {
/// Returns JSON result with content array and isError flag per MCP spec
pub async fn handle_tools_call<C>(
node_service: &Arc<NodeService<C>>,
embedding_service: &Arc<NodeEmbeddingService<C>>,
embedding_service: &Option<Arc<NodeEmbeddingService<C>>>,
params: Value,
) -> Result<Value, MCPError>
where
Expand Down Expand Up @@ -384,10 +384,15 @@ where
"get_nodes_batch" => nodes::handle_get_nodes_batch(node_service, arguments).await,
"update_nodes_batch" => nodes::handle_update_nodes_batch(node_service, arguments).await,

// Search
"search_semantic" => {
search::handle_search_semantic(node_service, embedding_service, arguments).await
}
// Search — returns graceful error when embeddings are unavailable
"search_semantic" => match embedding_service {
Some(emb_svc) => {
search::handle_search_semantic(node_service, emb_svc, arguments).await
}
None => Err(MCPError::internal_error(
"Semantic search unavailable: embedding model failed to load. Node CRUD tools are still available.".to_string(),
)),
},

// Discovery
"search_tools" => handle_search_tools(arguments),
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/mcp/handlers/tools_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ mod async_integration_tests {
use std::sync::Arc;
use tempfile::TempDir;

async fn setup_test_services() -> (Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir) {
async fn setup_test_services() -> (Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir)
{
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("test.db");

Expand All @@ -150,7 +151,7 @@ mod async_integration_tests {

let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));

(node_service, embedding_service, temp_dir)
(node_service, Some(embedding_service), temp_dir)
}

#[tokio::test]
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ where
C: surrealdb::Connection,
{
pub node_service: Arc<NodeService<C>>,
pub embedding_service: Arc<NodeEmbeddingService<C>>,
/// Optional: MCP still serves node CRUD without embeddings; semantic search
/// returns a graceful error when this is `None`.
pub embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
}

/// Server state tracking initialization status
Expand Down Expand Up @@ -731,7 +733,7 @@ mod tests {

McpServices {
node_service,
embedding_service,
embedding_service: Some(embedding_service),
}
}

Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/services/mcp_server_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ where
C: surrealdb::Connection,
{
node_service: Arc<NodeService<C>>,
embedding_service: Arc<NodeEmbeddingService<C>>,
/// Optional: MCP still serves node CRUD without embeddings; semantic search
/// returns a graceful error when this is `None`.
embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
port: u16,
}

Expand All @@ -81,7 +83,9 @@ where
/// # Arguments
///
/// * `node_service` - Shared NodeService instance for node operations
/// * `embedding_service` - Shared embedding service for semantic search
/// * `embedding_service` - Optional embedding service for semantic search.
/// When `None`, MCP still serves node CRUD; semantic search returns a
/// graceful error.
/// * `port` - HTTP port to listen on (typically 3100)
///
/// # Example
Expand All @@ -92,11 +96,11 @@ where
/// .and_then(|p| p.parse().ok())
/// .unwrap_or(3100);
///
/// let service = McpServerService::new(node_service, embedding_service, port);
/// let service = McpServerService::new(node_service, Some(embedding_service), port);
/// ```
pub fn new(
node_service: Arc<NodeService<C>>,
embedding_service: Arc<NodeEmbeddingService<C>>,
embedding_service: Option<Arc<NodeEmbeddingService<C>>>,
port: u16,
) -> Self {
Self {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/tests/mcp_handlers_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async fn create_test_env() -> anyhow::Result<(Arc<NodeService>, TempDir)> {

/// Test helper: Create a test environment with NodeService and NodeEmbeddingService
async fn create_test_env_with_embedding(
) -> anyhow::Result<(Arc<NodeService>, Arc<NodeEmbeddingService>, TempDir)> {
) -> anyhow::Result<(Arc<NodeService>, Option<Arc<NodeEmbeddingService>>, TempDir)> {
let temp_dir = TempDir::new()?;
let db_path = temp_dir.path().join("test.db");
let mut store = Arc::new(SurrealStore::new(db_path).await?);
Expand All @@ -47,7 +47,7 @@ async fn create_test_env_with_embedding(

let embedding_service = Arc::new(NodeEmbeddingService::new(nlp_engine, store.clone()));

Ok((node_service, embedding_service, temp_dir))
Ok((node_service, Some(embedding_service), temp_dir))
}

// ============================================================================
Expand Down
Loading