diff --git a/crates/tinymemory-api/src/null.rs b/crates/tinymemory-api/src/null.rs index 9e737c4..dffd04f 100644 --- a/crates/tinymemory-api/src/null.rs +++ b/crates/tinymemory-api/src/null.rs @@ -64,7 +64,7 @@ use crate::provider::{ CodingSessionIngestRequest, CodingSessionSource, CoverWindowQuery, EntityMatch, FacetType, FastRetrieveQuery, MemoryChunks, MemoryCodingSessions, MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryGoals, MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, - MemoryPortability, MemoryProfile, MemoryProvider, MemoryRecall, MemoryRetrieval, + MemoryPortability, MemoryProfile, MemoryProvider, MemoryRecall, MemoryRetrieval, MemoryScoring, MemorySourceSink, MemorySourceSync, MemoryToolMemory, MemoryTree, PersonHandle, PersonInteraction, PersonRecord, PersonScore, ProfileFacet, RankedPerson, RawArchiveCoverage, RawRebuildOutcome, ResolvedPerson, RetrievalHit, RetrievalResponse, SourceRetrievalQuery, @@ -785,6 +785,21 @@ impl MemoryCodingSessions for NullMemoryProvider { } } +#[async_trait] +impl MemoryScoring for NullMemoryProvider { + async fn extract_entities(&self, _query: &str) -> Result, MemoryError> { + unsupported(Capability::Scoring) + } + + async fn embed_text(&self, _text: &str) -> Result, MemoryError> { + unsupported(Capability::Scoring) + } + + async fn embedder_slug(&self) -> Result { + unsupported(Capability::Scoring) + } +} + #[cfg(test)] #[path = "null_tests.rs"] mod tests; diff --git a/crates/tinymemory-api/src/provider/driver.rs b/crates/tinymemory-api/src/provider/driver.rs index b3768ce..c25769f 100644 --- a/crates/tinymemory-api/src/provider/driver.rs +++ b/crates/tinymemory-api/src/provider/driver.rs @@ -66,6 +66,7 @@ use crate::provider::records::{ MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory, }; use crate::provider::retrieval::MemoryRetrieval; +use crate::provider::scoring::MemoryScoring; use crate::provider::sessions::MemoryCodingSessions; use crate::provider::sync::MemorySourceSync; @@ -214,6 +215,11 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati None } + /// Scoring and NLP operations, when advertised. + fn as_scoring(&self) -> Option<&dyn MemoryScoring> { + None + } + /// Whether `capability` is actually **reachable** on this driver. /// /// This is the implementation-side truth, as opposed to @@ -246,6 +252,7 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati Capability::Episodic => self.as_episodic().is_some(), Capability::SourceSync => self.as_source_sync().is_some(), Capability::CodingSessions => self.as_coding_sessions().is_some(), + Capability::Scoring => self.as_scoring().is_some(), } } } diff --git a/crates/tinymemory-api/src/provider/mod.rs b/crates/tinymemory-api/src/provider/mod.rs index 01c1591..7b74eb7 100644 --- a/crates/tinymemory-api/src/provider/mod.rs +++ b/crates/tinymemory-api/src/provider/mod.rs @@ -76,6 +76,7 @@ pub mod people; pub mod profile; pub mod records; pub mod retrieval; +pub mod scoring; pub mod sessions; pub mod sync; // The value types every family exchanges, defined in `tinymemory-bus` and @@ -109,6 +110,7 @@ pub use retrieval::{ CoverWindowQuery, EntityMatch, FastRetrieveQuery, MemoryRetrieval, RetrievalHit, RetrievalNodeKind, RetrievalResponse, SourceRetrievalQuery, }; +pub use scoring::MemoryScoring; pub use sessions::{ CodingSessionIngestReport, CodingSessionIngestRequest, CodingSessionSource, MemoryCodingSessions, diff --git a/crates/tinymemory-api/src/provider/scoring.rs b/crates/tinymemory-api/src/provider/scoring.rs new file mode 100644 index 0000000..2d8a8d1 --- /dev/null +++ b/crates/tinymemory-api/src/provider/scoring.rs @@ -0,0 +1,61 @@ +//! [`MemoryScoring`] — scoring and NLP operations exposed over the bus. +//! +//! This family carries the three operations that currently keep +//! `tinymemory-core` in the host's build graph: entity extraction, text +//! embedding, and embedder identification. Moving them behind the bus lets +//! every call site that reached the engine directly for these purposes route +//! through the contract instead. +//! +//! ## Design note — why the host requests, not constructs +//! +//! The host previously constructed an embedder from config and called it +//! directly. That pattern cannot cross the bus: config is host-side, the +//! embedder lives in the module. The correct shape is that the host asks the +//! driver to perform the operation by intent (`embed_text`) and to identify +//! which provider is active (`embedder_slug`), delegating both the construction +//! and the execution to the driver. + +use async_trait::async_trait; + +use crate::error::MemoryError; + +/// Scoring and NLP operations exposed over the bus. +#[async_trait] +pub trait MemoryScoring: Send + Sync { + /// Extract canonical entity strings from a natural-language query. + /// + /// Returns `":"` strings in the same namespace as the indexed + /// chunk entities. An empty result means the query is ungrounded — no + /// entity anchors were found — which routes retrieval toward the global + /// (dense) branch rather than the entity-indexed branch. + /// + /// Never fails: when the NLP backend is unavailable the implementation + /// degrades to a regex extractor rather than returning an error. + /// + /// # Errors + /// + /// Only infrastructure failures (e.g. the module bus is down). The NLP + /// step itself never errors — it degrades gracefully. + async fn extract_entities(&self, query: &str) -> Result, MemoryError>; + + /// Embed a text string with the active embedder. + /// + /// Returns a float vector; the length matches the active embedding + /// dimension (currently 1024 for the default bge-m3 model). + /// + /// # Errors + /// + /// When no embedder is configured (`Unsupported`) or the embedding call + /// fails (e.g. the Ollama server is unreachable). + async fn embed_text(&self, text: &str) -> Result, MemoryError>; + + /// Stable string identifying which embedder provider is currently active. + /// + /// One of: `"ollama"`, `"none"`, `"custom"`, `"cloud"`, `"unconfigured"`. + /// Used by the host to decide how to attribute embedding costs in the UI. + /// + /// # Errors + /// + /// Only infrastructure failures. Config resolution itself never errors. + async fn embedder_slug(&self) -> Result; +} diff --git a/crates/tinymemory-bus/src/capabilities.rs b/crates/tinymemory-bus/src/capabilities.rs index 2a4600d..a90edf9 100644 --- a/crates/tinymemory-bus/src/capabilities.rs +++ b/crates/tinymemory-bus/src/capabilities.rs @@ -113,6 +113,9 @@ pub enum Capability { /// walk. Advertising them together would put a dead control in front of /// whichever half is absent. CodingSessions, + /// Scoring and NLP operations: entity extraction, text embedding, and + /// embedder identification. + Scoring, } impl Capability { @@ -121,7 +124,7 @@ impl Capability { /// Declaration order is also bit order in [`Capabilities`] and iteration /// order in its serialized form, so this slice is the single ordering /// authority for the whole module. - pub const ALL: [Capability; 20] = [ + pub const ALL: [Capability; 21] = [ Capability::Core, Capability::Recall, Capability::Ingest, @@ -145,6 +148,7 @@ impl Capability { Capability::Episodic, Capability::SourceSync, Capability::CodingSessions, + Capability::Scoring, ]; /// The families a driver must advertise to be bindable at all. @@ -190,6 +194,7 @@ impl Capability { Self::Episodic => "episodic", Self::SourceSync => "source_sync", Self::CodingSessions => "coding_sessions", + Self::Scoring => "scoring", } } @@ -239,6 +244,7 @@ impl Capability { Self::Episodic => 17, Self::SourceSync => 18, Self::CodingSessions => 19, + Self::Scoring => 20, } } diff --git a/crates/tinymemory-bus/src/capabilities_tests.rs b/crates/tinymemory-bus/src/capabilities_tests.rs index 6f0ce9e..19f9230 100644 --- a/crates/tinymemory-bus/src/capabilities_tests.rs +++ b/crates/tinymemory-bus/src/capabilities_tests.rs @@ -2,7 +2,7 @@ //! //! Three properties are load-bearing and each has its own test: //! -//! 1. the enum has exactly the twenty contract families and no more; +//! 1. the enum has exactly the twenty-one contract families and no more; //! 2. the serialized form is stable snake_case **strings**, never discriminant //! integers — a driver deployed against an older build must keep advertising //! the same set after a variant is inserted mid-enum; @@ -19,9 +19,9 @@ use super::*; use serde_json::json; #[test] -fn capability_has_exactly_the_twenty_contract_families() { - assert_eq!(Capability::ALL.len(), 20); - assert_eq!(Capability::all().len(), 20); +fn capability_has_exactly_the_twenty_one_contract_families() { + assert_eq!(Capability::ALL.len(), 21); + assert_eq!(Capability::all().len(), 21); let names: Vec<&str> = Capability::ALL.iter().map(|c| c.as_str()).collect(); assert_eq!( @@ -47,6 +47,7 @@ fn capability_has_exactly_the_twenty_contract_families() { "episodic", "source_sync", "coding_sessions", + "scoring", ] ); } diff --git a/crates/tinymemory-bus/src/names.rs b/crates/tinymemory-bus/src/names.rs index 9ebdf9e..065f588 100644 --- a/crates/tinymemory-bus/src/names.rs +++ b/crates/tinymemory-bus/src/names.rs @@ -316,6 +316,14 @@ pub mod methods { pub const CODING_SESSION_STATUS: &str = "CodingSessionStatus"; /// `IngestCodingSessions` — distil coding sessions into observations. pub const INGEST_CODING_SESSIONS: &str = "IngestCodingSessions"; + + // Scoring family — entity extraction and text embedding through the bus. + /// `ExtractEntities` — extract canonical entity ids from a query string. + pub const EXTRACT_ENTITIES: &str = "ExtractEntities"; + /// `EmbedText` — produce a dense embedding vector for an arbitrary string. + pub const EMBED_TEXT: &str = "EmbedText"; + /// `EmbedderSlug` — the stable identifier of the active embedder. + pub const EMBEDDER_SLUG: &str = "EmbedderSlug"; } /// Every member name, in the order the module declares them. @@ -323,7 +331,7 @@ pub mod methods { /// The order matters: `tinybus`'s `Interface::members()` returns declaration /// order, and the module compares the two sequences directly rather than as /// sets, so a reordering is caught alongside an addition or a removal. -pub const METHODS: [&str; 123] = [ +pub const METHODS: [&str; 126] = [ methods::DRIVER_ID, methods::CAPABILITIES, methods::HEALTH, @@ -447,6 +455,9 @@ pub const METHODS: [&str; 123] = [ methods::REBUILD_FROM_RAW_ARCHIVE, methods::CODING_SESSION_STATUS, methods::INGEST_CODING_SESSIONS, + methods::EXTRACT_ENTITIES, + methods::EMBED_TEXT, + methods::EMBEDDER_SLUG, ]; #[cfg(test)] diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index cd0ba88..70d56ed 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -783,6 +783,10 @@ mod exports { // Local coding-agent transcripts. "CodingSessionStatus", "IngestCodingSessions", + // Scoring: entity extraction, text embedding, embedder identification. + "ExtractEntities", + "EmbedText", + "EmbedderSlug", ], signals = [], // The host's embedder is deliberately NOT declared as `requires`. That diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index 29126d6..92766b9 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -77,6 +77,10 @@ //! //! CodingSessionStatus() -> [CodingSessionSource] //! IngestCodingSessions(request) -> CodingSessionIngestReport +//! +//! ExtractEntities(query) -> [String] +//! EmbedText(text) -> [f32] +//! EmbedderSlug() -> String //! ``` //! //! # Source scope crosses as an argument, never as ambient state @@ -1896,6 +1900,27 @@ impl MemoryService { .await .map_err(|error| into_bus_error(&error)) } + + async fn extract_entities(&self, query: String) -> BusResult> { + require_family!(self, as_scoring, Capability::Scoring) + .extract_entities(&query) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn embed_text(&self, text: String) -> BusResult> { + require_family!(self, as_scoring, Capability::Scoring) + .embed_text(&text) + .await + .map_err(|error| into_bus_error(&error)) + } + + async fn embedder_slug(&self) -> BusResult { + require_family!(self, as_scoring, Capability::Scoring) + .embedder_slug() + .await + .map_err(|error| into_bus_error(&error)) + } } /// The response-size ceiling for a method that returns a list of entries. diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 315ff45..398acf7 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -705,6 +705,10 @@ const EXPECTED_METHODS: &[&str] = &[ "RebuildFromRawArchive", "CodingSessionStatus", "IngestCodingSessions", + // Scoring family. + "ExtractEntities", + "EmbedText", + "EmbedderSlug", ]; #[tokio::test] diff --git a/crates/tinymemory-tinycortex/src/engine/mod.rs b/crates/tinymemory-tinycortex/src/engine/mod.rs index 744bd45..a39b7b2 100644 --- a/crates/tinymemory-tinycortex/src/engine/mod.rs +++ b/crates/tinymemory-tinycortex/src/engine/mod.rs @@ -49,11 +49,11 @@ use tinymemory_api::provider::{ FacetType, FastRetrieveQuery, MemoryChunks, MemoryCodingSessions, MemoryCore, MemoryDiff, MemoryDocuments, MemoryEntities, MemoryEpisodic, MemoryGoals, MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople, MemoryPortability, MemoryProfile, MemoryProvider, - MemoryRecall, MemoryRetrieval, MemorySourceSink, MemorySourceSync, MemoryToolMemory, - MemoryTree, PersonHandle, PersonInteraction, PersonRecord, PersonScore, ProfileFacet, - RankedPerson, RawArchiveCoverage, RawRebuildOutcome, ResolvedPerson, RetrievalHit, - RetrievalResponse, SourceRetrievalQuery, SourceSyncState, SourceSyncStatus, SourceTotal, - SyncAuditEntry, SyncFreshness, SyncRunOutcome, UserState, + MemoryRecall, MemoryRetrieval, MemoryScoring, MemorySourceSink, MemorySourceSync, + MemoryToolMemory, MemoryTree, PersonHandle, PersonInteraction, PersonRecord, PersonScore, + ProfileFacet, RankedPerson, RawArchiveCoverage, RawRebuildOutcome, ResolvedPerson, + RetrievalHit, RetrievalResponse, SourceRetrievalQuery, SourceSyncState, SourceSyncStatus, + SourceTotal, SyncAuditEntry, SyncFreshness, SyncRunOutcome, UserState, }; use tinymemory_api::recall::OwnedRecallOpts; use tinymemory_api::tool_memory::ToolMemoryRule; @@ -2343,6 +2343,9 @@ impl MemoryProvider for TinycortexProvider { fn as_coding_sessions(&self) -> Option<&dyn MemoryCodingSessions> { Some(self) } + fn as_scoring(&self) -> Option<&dyn MemoryScoring> { + Some(self) + } } // ── Source sync ────────────────────────────────────────────────────────────── @@ -2786,6 +2789,46 @@ impl MemoryCodingSessions for TinycortexProvider { } } +// ── Scoring ────────────────────────────────────────────────────────────────── + +#[async_trait] +impl MemoryScoring for TinycortexProvider { + async fn extract_entities(&self, query: &str) -> Result, MemoryError> { + let config = self.config.clone(); + let query = query.to_owned(); + let entities = tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on( + tinymemory_core::tree::nlp::extract_query_entities(&config, &query), + ) + }) + .await + .map_err(|error| Self::other("extract entities", error))?; + Ok(entities.into_iter().map(|e| e.canonical_id).collect()) + } + + async fn embed_text(&self, text: &str) -> Result, MemoryError> { + let config = self.config.clone(); + let text = text.to_owned(); + tokio::task::spawn_blocking(move || { + let embedder = + tinymemory_core::tree::score::embed::factory::build_embedder_from_config(&config) + .map_err(|error| MemoryError::Other(anyhow::anyhow!("{error}")))?; + tokio::runtime::Handle::current() + .block_on(embedder.embed(&text)) + .map_err(|error| MemoryError::Other(anyhow::anyhow!("{error}"))) + }) + .await + .map_err(|error| Self::other("embed text", error))? + } + + async fn embedder_slug(&self) -> Result { + Ok( + tinymemory_core::tree::score::embed::factory::effective_embedder_slug(&self.config) + .to_string(), + ) + } +} + // ── People ─────────────────────────────────────────────────────────────────── // // The conversions below destructure both sides exhaustively rather than