-
Notifications
You must be signed in to change notification settings - Fork 0
feat(belief-nodes): embed-on-write for Pen + engine-nap (Block D PR 1) #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| //! Shared embed-on-write helper for `belief_nodes` producers. | ||
| //! | ||
| //! Per `docs/specs/belief-nodes-embedding-Spec.md` §4 + §13.1 (universal | ||
| //! embed-on-write principle), every producer of cognitive content embeds | ||
| //! the text before insert. This module provides the common helper that | ||
| //! Pen (`tools/notes.rs`) and engine-nap (`engine/runtime.rs`) both call, | ||
| //! plus future single-doc producers added under §13.7. | ||
| //! | ||
| //! The Spec's failure-mode escape (§4.1.2, §13.1) is encoded here: on | ||
| //! embedder unavailability or call failure, the helper logs a warning | ||
| //! and returns without mutating the document. The caller proceeds with | ||
| //! the insert; the doc lands with no embedding field; sleep stage A's | ||
| //! retroactive pass (§7) populates it on the next sleep cycle. Producers | ||
| //! never block on embedder availability. | ||
| //! | ||
| //! Batched producers (preseed materializer per §4.3) do not use this | ||
| //! helper — they call `embedder.embed(&[texts], ...)` directly so the | ||
| //! encoder batches and amortizes per-call overhead. | ||
| //! | ||
| //! ## Why a shared module instead of inline in each producer | ||
| //! | ||
| //! - Single point of contract enforcement for the embedding field | ||
| //! shape (`embedding` / `embedding_model` / `embedding_dim` / | ||
| //! `embedding_task`). | ||
| //! - Single point for the failure-mode telemetry surface — operators | ||
| //! inspecting logs see one canonical warning shape across producers. | ||
| //! - When the substrate-gateway pattern lands per Spec §13.8, the | ||
| //! refactor touches one helper rather than every producer. | ||
|
|
||
| use serde_json::{Value, json}; | ||
| use std::sync::Arc; | ||
|
|
||
| use crate::embedder::Embedder; | ||
| use weaver_database::graph::belief::{ | ||
| BELIEF_EMBEDDING_DIM_FIELD, BELIEF_EMBEDDING_FIELD, BELIEF_EMBEDDING_MODEL_FIELD, | ||
| BELIEF_EMBEDDING_TASK_FIELD, | ||
| }; | ||
|
|
||
| /// Jina V4 task hint for stored documents per Spec §2.1 — selects the | ||
| /// `retrieval` adapter with `"Passage: "` prefix at embed time. | ||
| pub const PASSAGE_TASK: &str = "retrieval.passage"; | ||
|
|
||
| /// Model identifier stamped on the `embedding_model` field. Matches | ||
| /// the canonical name used by Spec §4.1.2's example and by the Spec's | ||
| /// migration-drift filter (`FILTER doc.embedding_model != "jina-v4"`). | ||
| pub const JINA_V4_MODEL_NAME: &str = "jina-v4"; | ||
|
|
||
| /// Embed `text` via the supplied embedder and stamp the four embedding | ||
| /// fields onto `doc` per `belief-nodes-embedding-Spec.md` §4.1.2. | ||
| /// | ||
| /// `producer_label` is a short string (e.g. `"Pen"`, `"engine_nap"`) | ||
| /// used in tracing output so operators can identify which producer's | ||
| /// write degraded under embedder failure. | ||
| /// | ||
| /// On `embedder == None` or call failure, logs a warning and returns | ||
| /// without mutating `doc`. The caller proceeds with the insert; the | ||
| /// doc lands with no embedding field; sleep stage A's retroactive | ||
| /// pass populates it later. Producers never block on embedder | ||
| /// availability — embedder failure degrades the substrate per Spec | ||
| /// §13.1's failure-mode escape, it does not fail the write. | ||
| pub async fn try_embed_and_stamp_belief_node( | ||
| embedder: Option<&Arc<dyn Embedder>>, | ||
| text: &str, | ||
| producer_label: &str, | ||
| doc: &mut Value, | ||
| ) { | ||
| let Some(embedder) = embedder else { | ||
| tracing::warn!( | ||
| producer = %producer_label, | ||
| "no embedder configured; document will land with embedding: null. \ | ||
| Degraded mode — production agents should always have an embedder. \ | ||
| Sleep stage A retroactive pass will populate the embedding later." | ||
| ); | ||
| return; | ||
| }; | ||
|
|
||
| match embedder.embed_one(text, PASSAGE_TASK).await { | ||
| Ok(vec) => { | ||
| let dim = vec.len(); | ||
| doc[BELIEF_EMBEDDING_FIELD] = json!(vec); | ||
| doc[BELIEF_EMBEDDING_MODEL_FIELD] = json!(JINA_V4_MODEL_NAME); | ||
| doc[BELIEF_EMBEDDING_DIM_FIELD] = json!(dim); | ||
| doc[BELIEF_EMBEDDING_TASK_FIELD] = json!(PASSAGE_TASK); | ||
| } | ||
| Err(e) => { | ||
| tracing::warn!( | ||
| error = %e, | ||
| producer = %producer_label, | ||
| "embed call failed; document will land with embedding: null. \ | ||
| Sleep stage A retroactive pass will populate it on next sleep." | ||
| ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use crate::embedder::{EmbedResult, Embedder, EmbedderInfo, EmbeddingError, LateChunkedResult}; | ||
| use async_trait::async_trait; | ||
|
|
||
| /// Fixed-dimension fixture embedder for testing. Returns a vector of | ||
| /// the configured dimension filled with a deterministic ramp so | ||
| /// stamped values are inspectable in assertions. | ||
| struct FixtureEmbedder { | ||
| dim: usize, | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl Embedder for FixtureEmbedder { | ||
| async fn embed( | ||
| &self, | ||
| texts: &[String], | ||
| _task: &str, | ||
| _batch_size: Option<u32>, | ||
| ) -> Result<EmbedResult, EmbeddingError> { | ||
| Ok(EmbedResult { | ||
| embeddings: texts | ||
| .iter() | ||
| .map(|_| (0..self.dim).map(|i| i as f32).collect()) | ||
| .collect(), | ||
| model: "fixture".into(), | ||
| dimension: self.dim as u32, | ||
| duration_ms: 0, | ||
| }) | ||
| } | ||
| async fn embed_late_chunked( | ||
| &self, | ||
| _: &str, | ||
| _: &str, | ||
| ) -> Result<LateChunkedResult, EmbeddingError> { | ||
| unreachable!() | ||
| } | ||
| async fn info(&self) -> Result<EmbedderInfo, EmbeddingError> { | ||
| unreachable!() | ||
| } | ||
| } | ||
|
|
||
| /// An embedder that always fails — exercises the failure-mode escape. | ||
| struct FailingEmbedder; | ||
|
|
||
| #[async_trait] | ||
| impl Embedder for FailingEmbedder { | ||
| async fn embed( | ||
| &self, | ||
| _: &[String], | ||
| _: &str, | ||
| _: Option<u32>, | ||
| ) -> Result<EmbedResult, EmbeddingError> { | ||
| Err(EmbeddingError::NotAvailable("test failure".into())) | ||
| } | ||
| async fn embed_late_chunked( | ||
| &self, | ||
| _: &str, | ||
| _: &str, | ||
| ) -> Result<LateChunkedResult, EmbeddingError> { | ||
| unreachable!() | ||
| } | ||
| async fn info(&self) -> Result<EmbedderInfo, EmbeddingError> { | ||
| unreachable!() | ||
| } | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn stamps_four_fields_on_success() { | ||
| let embedder: Arc<dyn Embedder> = Arc::new(FixtureEmbedder { dim: 8 }); | ||
| let mut doc = json!({ "topic": "t", "content": "c" }); | ||
|
|
||
| try_embed_and_stamp_belief_node(Some(&embedder), "t\nc", "test", &mut doc).await; | ||
|
|
||
| assert_eq!(doc[BELIEF_EMBEDDING_FIELD].as_array().unwrap().len(), 8); | ||
| assert_eq!(doc[BELIEF_EMBEDDING_MODEL_FIELD], json!(JINA_V4_MODEL_NAME)); | ||
| assert_eq!(doc[BELIEF_EMBEDDING_DIM_FIELD], json!(8)); | ||
| assert_eq!(doc[BELIEF_EMBEDDING_TASK_FIELD], json!(PASSAGE_TASK)); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn does_not_mutate_doc_when_embedder_absent() { | ||
| let mut doc = json!({ "topic": "t", "content": "c" }); | ||
| let before = doc.clone(); | ||
|
|
||
| try_embed_and_stamp_belief_node(None, "t\nc", "test", &mut doc).await; | ||
|
|
||
| assert_eq!(doc, before, "doc must not gain embedding fields under None"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn does_not_mutate_doc_when_embedder_fails() { | ||
| let embedder: Arc<dyn Embedder> = Arc::new(FailingEmbedder); | ||
| let mut doc = json!({ "topic": "t", "content": "c" }); | ||
| let before = doc.clone(); | ||
|
|
||
| try_embed_and_stamp_belief_node(Some(&embedder), "t\nc", "test", &mut doc).await; | ||
|
|
||
| assert_eq!( | ||
| doc, before, | ||
| "doc must not gain embedding fields when embedder errors" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn embedding_dim_reflects_actual_vector_length() { | ||
| // The dim field reports the actual vector length (Spec §2.1): | ||
| // "Redundant with embedding.len() but cheap to filter on — a | ||
| // single-field FILTER doc.embedding_dim != 2048 query identifies | ||
| // migration-stragglers without parsing the array." | ||
| // Stamping a constant 2048 would mask drift. | ||
| for dim in [4, 16, 64, 2048] { | ||
| let embedder: Arc<dyn Embedder> = Arc::new(FixtureEmbedder { dim }); | ||
| let mut doc = json!({}); | ||
| try_embed_and_stamp_belief_node(Some(&embedder), "x", "test", &mut doc).await; | ||
| assert_eq!(doc[BELIEF_EMBEDDING_DIM_FIELD], json!(dim)); | ||
| assert_eq!( | ||
| doc[BELIEF_EMBEDDING_FIELD].as_array().unwrap().len(), | ||
| dim, | ||
| "actual vector length matches stamped dim" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.