Skip to content

Commit 4ab4d55

Browse files
Let configuration select the memory engine, gated by the registry
Issue #18 §A5. The driver registry could answer "is this driver id real, and is it allowed to answer for memory", and nothing asked it: `DriverRegistry::admit` had no caller outside its own tests, `MemoryHostConfig::memory_provider()` had no reader, and the memory client factory constructed TinyCortex unconditionally. Configuration could not choose an engine. `DriverRegistry::select` closes that: it reads the engine from the host's configuration and puts it through `admit`, so both surfaces are now live. Two corrections to the issue, both load-bearing. §A5 names `memory_provider()` as the selector. That method is a `provider:model` routing string for the memory *workload* — which language model does summarisation and entity extraction — not the store the memory lives in. Reading it would have let a model change repoint a company's storage. Selection reads a new `memory_driver()` instead, defaulted to `None` so it breaks no existing implementation, and a test pins that the two fields stay independent. §A5 also asks that `create_memory_*` return a bound `Arc<dyn MemoryProvider>`. It cannot, and the reason is structural rather than unfinished: since §C3 `adapters/tinycortex` depends on `tinymemory-core`, so a core factory returning a constructed adapter provider is a dependency cycle. Selection therefore resolves the decision and the host constructs — which is what `src/registry`'s module docs have said all along: "It resolves the class, not the instance." A configuration naming no engine gets the reserved embedded default, so adding selection does not turn "I configured nothing" into a host that fails to start. Going through `select` does not loosen admission either: an external engine named in config is still refused without endpoint, credential and trust. Refs #18 (§A5)
1 parent 26629c6 commit 4ab4d55

6 files changed

Lines changed: 219 additions & 9 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ serde = { version = "1", features = ["derive"] }
7272
[dev-dependencies]
7373
# The mandatory-family tests are async.
7474
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
75+
# `TestHostConfig`, for the driver-selection tests. A dev-dependency: the
76+
# facade must not carry a test double into a consumer's graph.
77+
tinymemory-api = { path = "api", features = ["test-support"] }
7578
# The reference driver and the behavioural suite, for the workspace-level
7679
# integration tests. A dev-dependency only: the facade must not carry a test
7780
# harness into a consumer's dependency graph.

api/src/host/config.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,25 @@ pub trait MemoryHostConfig: Send + Sync + std::fmt::Debug {
125125
/// `provider:model` routing string for the memory workload, if pinned.
126126
fn memory_provider(&self) -> Option<&str>;
127127

128+
/// The memory **engine** this host selects, when its configuration names
129+
/// one — `tinycortex`, `supermemory`, `mem0`, `cognee`, `null`.
130+
///
131+
/// Deliberately distinct from [`Self::memory_provider`], which despite the
132+
/// name is a `provider:model` routing string for the memory *workload* —
133+
/// which language model does summarisation and entity extraction. That is a
134+
/// different axis from which store the memory lives in, and conflating them
135+
/// would let a model change repoint a company's storage.
136+
///
137+
/// `None` means "the host's default", which the host resolves rather than
138+
/// this trait: the driver registry admits a reserved embedded id with no
139+
/// configuration entry precisely so an unconfigured host still binds
140+
/// something instead of failing to start.
141+
///
142+
/// Defaulted so adding it breaks no existing implementation.
143+
fn memory_driver(&self) -> Option<&str> {
144+
None
145+
}
146+
128147
/// The local model id for a workload, when that workload is routed to
129148
/// Ollama (`"ollama:<model>"`). `None` for cloud or unset workloads.
130149
///

api/src/host/test_support.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ pub struct TestHostConfig {
4545
pub embeddings_provider: Option<String>,
4646
/// See [`MemoryHostConfig::memory_provider`].
4747
pub memory_provider: Option<String>,
48+
/// See [`MemoryHostConfig::memory_driver`]. `None` selects the host default.
49+
pub memory_driver: Option<String>,
4850
/// See [`MemoryHostConfig::api_url`].
4951
pub api_url: Option<String>,
5052
/// See [`MemoryHostConfig::default_model`].
@@ -113,6 +115,10 @@ impl MemoryHostConfig for TestHostConfig {
113115
self.memory_provider.as_deref()
114116
}
115117

118+
fn memory_driver(&self) -> Option<&str> {
119+
self.memory_driver.as_deref()
120+
}
121+
116122
fn workload_local_model(&self, workload: &str) -> Option<String> {
117123
let raw = match workload {
118124
"memory" => self.memory_provider.as_deref(),

src/registry/mod.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
use std::collections::BTreeMap;
4141
use std::fmt;
4242

43+
use tinymemory_api::host::MemoryHostConfig;
44+
4345
mod class;
4446

4547
pub use class::{DriverClass, DriverClassParseError};
@@ -284,6 +286,43 @@ impl DriverRegistry {
284286
Ok(admission)
285287
}
286288

289+
/// Selects and admits the memory driver this host's configuration names.
290+
///
291+
/// The half of driver binding that was specified but never wired: the
292+
/// registry could answer "is this driver id real and allowed", and nothing
293+
/// asked it. This reads the id from the host's own configuration and puts
294+
/// it through [`Self::admit`], so configuration decides the engine instead
295+
/// of a factory hardcoding one (issue #18 §A5).
296+
///
297+
/// It reads [`MemoryHostConfig::memory_driver`], **not**
298+
/// `memory_provider` — despite the name, the latter is a `provider:model`
299+
/// routing string choosing which language model does summarisation, which
300+
/// is a different axis from which store the memory lives in.
301+
///
302+
/// A configuration that names no driver gets [`TINYCORTEX_DRIVER_ID`], the
303+
/// reserved embedded default. That is what keeps an unconfigured host
304+
/// booting: an embedded id is admitted without a `drivers` entry, while an
305+
/// external one is refused without endpoint, credential and trust
306+
/// configuration.
307+
///
308+
/// This resolves the *decision*, not the instance. Constructing the
309+
/// provider, caching it per workspace, and wrapping it in a policy guard
310+
/// stay with the host — see the module docs for why.
311+
///
312+
/// # Errors
313+
///
314+
/// Returns the [`FallbackReason`] to record and publish when the configured
315+
/// driver is refused, exactly as [`Self::admit`] does.
316+
pub fn select(
317+
&self,
318+
config: &dyn MemoryHostConfig,
319+
entry: Option<DriverEntry<'_>>,
320+
labels: ConfigLabels<'_>,
321+
) -> Result<Admission, FallbackReason> {
322+
let driver = config.memory_driver().unwrap_or(TINYCORTEX_DRIVER_ID);
323+
self.admit(driver, entry, labels)
324+
}
325+
287326
/// The class an id implies when nothing says otherwise.
288327
///
289328
/// `context` names which part of the config was missing; the refusal echoes

src/registry/test.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,77 @@ fn driver_class_serde_matches_the_config_spelling() {
259259
assert_eq!(json, format!("\"{}\"", class.as_str()));
260260
}
261261
}
262+
263+
// ── Selection from configuration (issue #18 §A5) ─────────────────────────────
264+
//
265+
// Before this, the registry could answer "is this driver id real and allowed"
266+
// and nothing asked it: `admit` had no production caller, and the memory client
267+
// factory constructed TinyCortex unconditionally. These pin the wiring.
268+
269+
use tinymemory_api::host::test_support::TestHostConfig;
270+
271+
fn config_naming(driver: Option<&str>) -> TestHostConfig {
272+
// `TestHostConfig` is `#[non_exhaustive]`, so it is built and then mutated
273+
// rather than named field-by-field — which is what its own docs ask for.
274+
let mut config = TestHostConfig::default();
275+
config.memory_driver = driver.map(str::to_owned);
276+
config
277+
}
278+
279+
#[test]
280+
fn a_configuration_naming_no_driver_gets_the_embedded_default() {
281+
// The property that keeps an unconfigured host booting: a reserved embedded
282+
// id is admitted without any `drivers` entry.
283+
let admission = DriverRegistry::builtin()
284+
.select(&config_naming(None), None, labels())
285+
.expect("an unconfigured host still binds");
286+
assert_eq!(admission.id, TINYCORTEX_DRIVER_ID);
287+
assert_eq!(admission.class, DriverClass::Embedded);
288+
}
289+
290+
#[test]
291+
fn a_configuration_naming_an_engine_selects_that_engine() {
292+
let admission = DriverRegistry::builtin()
293+
.select(&config_naming(Some(NULL_DRIVER_ID)), None, labels())
294+
.expect("the null driver is admitted without an entry");
295+
assert_eq!(admission.id, NULL_DRIVER_ID);
296+
assert_eq!(admission.class, DriverClass::Null);
297+
}
298+
299+
#[test]
300+
fn selecting_a_hosted_engine_still_requires_its_entry() {
301+
// Selection does not loosen admission: an external driver named in config
302+
// but left unconfigured is refused fail-closed, exactly as `admit` refuses
303+
// it directly.
304+
let reason = DriverRegistry::builtin()
305+
.select(&config_naming(Some("supermemory")), None, labels())
306+
.expect_err("an external driver with no entry must be refused");
307+
assert_eq!(reason.configured_driver, "supermemory");
308+
}
309+
310+
#[test]
311+
fn selecting_a_hosted_engine_succeeds_once_it_is_configured_and_trusted() {
312+
let entry = DriverEntry {
313+
class: None,
314+
trust_state: TRUSTED,
315+
};
316+
let admission = DriverRegistry::builtin()
317+
.select(&config_naming(Some("supermemory")), Some(entry), labels())
318+
.expect("a configured, trusted external driver is admitted");
319+
assert_eq!(admission.class, DriverClass::External);
320+
}
321+
322+
#[test]
323+
fn selection_reads_the_engine_field_and_not_the_model_routing_one() {
324+
// `memory_provider` is a `provider:model` routing string choosing which
325+
// language model does summarisation. Reading it here would let a model
326+
// change repoint a company's storage, which is why selection has its own
327+
// field.
328+
let mut config = TestHostConfig::default();
329+
config.memory_provider = Some("ollama:llama3".to_owned());
330+
config.memory_driver = None;
331+
let admission = DriverRegistry::builtin()
332+
.select(&config, None, labels())
333+
.expect("model routing must not affect engine selection");
334+
assert_eq!(admission.id, TINYCORTEX_DRIVER_ID);
335+
}

tests/driver_selection.rs

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,29 @@
33
//!
44
//! Exercises only the public surface of the `tinymemory` facade.
55
//!
6-
//! # Scope note
6+
//! # Selection
77
//!
8-
//! Issue #18 §E3 describes this file as also asserting that "the bound
9-
//! provider's `driver_id()` matches" the configured id. That step needs
10-
//! `MemoryHostConfig::memory_provider()` to actually select an engine, which is
11-
//! §A5 and does not exist yet — `create_memory_client_with_local_ai` still
12-
//! constructs TinyCortex unconditionally. The issue's own sequencing says to
13-
//! write these tests "against the *current* behaviour first", so this file
14-
//! pins what admission does today. The binding half joins it when §A5 lands,
15-
//! and this file is where it goes.
8+
//! Configuration now chooses the engine (§A5). One correction to the issue is
9+
//! worth recording here, because it would otherwise have wired the wrong thing:
10+
//! §A5 names `MemoryHostConfig::memory_provider()` as the selector, but that
11+
//! method is a `provider:model` routing string for the memory *workload* —
12+
//! which language model summarises — not the engine the memory lives in.
13+
//! Selection reads `memory_driver()` instead, added for the purpose.
14+
//!
15+
//! What still does not exist is the last clause of §A5, that `create_memory_*`
16+
//! return a bound `Arc<dyn MemoryProvider>`. It cannot, and the reason is
17+
//! structural rather than unfinished: `adapters/tinycortex` depends on
18+
//! `tinymemory-core` since §C3, so a core factory returning a constructed
19+
//! adapter provider would be a dependency cycle. Selection resolves the
20+
//! *decision*; the host constructs — which is what `src/registry`'s own module
21+
//! docs have always said.
1622
1723
// A failing assertion in a test *is* a panic; the crate-wide `expect_used` /
1824
// `unwrap_used` / `panic` lints exist to keep the library from panicking, not
1925
// the tests. Same allowance, and same reasoning, as `src/registry/test.rs`.
2026
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
2127

28+
use tinymemory::api::host::test_support::TestHostConfig;
2229
use tinymemory::registry::{
2330
ConfigLabels, DriverClass, DriverEntry, DriverRegistry, COGNEE_DRIVER_ID, MEM0_DRIVER_ID,
2431
SUPERMEMORY_DRIVER_ID, TINYCORTEX_DRIVER_ID, TRUSTED,
@@ -159,3 +166,65 @@ fn a_config_class_typo_is_echoed_back_to_the_operator() {
159166
reason.reason
160167
);
161168
}
169+
170+
// ── The selection half, through the public facade ────────────────────────────
171+
172+
fn config_naming(driver: Option<&str>) -> TestHostConfig {
173+
let mut config = TestHostConfig::default();
174+
config.memory_driver = driver.map(str::to_owned);
175+
config
176+
}
177+
178+
#[test]
179+
fn configuration_chooses_the_engine_and_admission_gates_it() {
180+
let admission = DriverRegistry::builtin()
181+
.select(
182+
&config_naming(Some(COGNEE_DRIVER_ID)),
183+
Some(trusted_external()),
184+
labels(),
185+
)
186+
.expect("a configured, trusted external engine binds");
187+
assert_eq!(admission.id, COGNEE_DRIVER_ID);
188+
assert_eq!(admission.class, DriverClass::External);
189+
}
190+
191+
#[test]
192+
fn an_unconfigured_host_still_binds_the_embedded_default() {
193+
// The property that matters most operationally: adding engine selection
194+
// must not turn "I configured nothing" into a host that fails to start.
195+
let admission = DriverRegistry::builtin()
196+
.select(&config_naming(None), None, labels())
197+
.expect("an unconfigured host binds the embedded default");
198+
assert_eq!(admission.id, TINYCORTEX_DRIVER_ID);
199+
assert_eq!(admission.class, DriverClass::Embedded);
200+
}
201+
202+
#[test]
203+
fn selection_does_not_loosen_the_fail_closed_external_gate() {
204+
// Going through `select` rather than `admit` must not become a way around
205+
// the trust requirement.
206+
let untrusted = DriverEntry {
207+
class: None,
208+
trust_state: "untrusted",
209+
};
210+
let reason = DriverRegistry::builtin()
211+
.select(
212+
&config_naming(Some(MEM0_DRIVER_ID)),
213+
Some(untrusted),
214+
labels(),
215+
)
216+
.expect_err("an untrusted external engine is refused however it was chosen");
217+
assert!(reason.reason.contains(TRUSTED), "{}", reason.reason);
218+
}
219+
220+
#[test]
221+
fn the_model_routing_field_cannot_repoint_the_store() {
222+
// `memory_provider` chooses a language model; `memory_driver` chooses the
223+
// store. Conflating them would let a model change move a company's memory.
224+
let mut config = TestHostConfig::default();
225+
config.memory_provider = Some("ollama:llama3".to_owned());
226+
let admission = DriverRegistry::builtin()
227+
.select(&config, None, labels())
228+
.expect("model routing leaves engine selection alone");
229+
assert_eq!(admission.id, TINYCORTEX_DRIVER_ID);
230+
}

0 commit comments

Comments
 (0)