diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79718fdb..17464c1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,15 +168,27 @@ jobs: # §E8. Enforce the repository's 80% floor over production sources only. # Test helpers and vendored code can make the aggregate look healthy # without exercising the libraries a release actually ships. + # + # `reference/full.rs` is excluded on the same ground, and it is the one + # exclusion here that needs its reasoning written down. It is a driver + # that exists to be bound by *other repositories'* test suites — it is + # what lets a host test its own layer above the contract without linking + # an engine. This workspace cannot exercise it without inventing suite + # assertions for every optional family, and every assertion added to + # `assert_provider` binds TinyCortex too (`full_provider_conformance` + # picks them up automatically), so "cover the double" would mean writing + # engine requirements to satisfy a coverage number. `reference/mod.rs` is + # deliberately NOT excluded: the suite drives it hard and it sits at ~90%, + # which is what a driver this workspace does own should look like. - name: Enforce production-source coverage run: | set -euo pipefail cargo llvm-cov --all-features --workspace \ - --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$' \ + --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$|/crates/tinymemory-conformance/src/reference/full\.rs$' \ --fail-under-lines 80 --summary-only \ | tee "$GITHUB_STEP_SUMMARY" cargo llvm-cov report \ - --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$' \ + --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$|/crates/tinymemory-conformance/src/reference/full\.rs$' \ --json --output-path target/production-coverage.json test_decl_line="$(grep -n '#\[cfg(test)\]' \ crates/tinymemory-api/src/host/local_ai.rs | tail -1 | cut -d: -f1)" @@ -358,7 +370,7 @@ jobs: set -euo pipefail cargo llvm-cov --manifest-path crates/tinymemory-module/Cargo.toml \ --workspace --all-features \ - --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$' \ + --ignore-filename-regex '(^|/)(tests|vendor)/|(^|/)(test|tests|test_helpers|test_support|test_seams)\.rs$|(_test|_tests|_test_support)\.rs$|/crates/tinymemory-core/src/(engine/parity|tree/retrieval/benchmarks)\.rs$|/crates/tinymemory-conformance/src/reference/full\.rs$' \ --fail-under-lines 80 --summary-only \ | tee "$GITHUB_STEP_SUMMARY" diff --git a/crates/tinymemory-api/src/lib.rs b/crates/tinymemory-api/src/lib.rs index 93d8846c..051f64d3 100644 --- a/crates/tinymemory-api/src/lib.rs +++ b/crates/tinymemory-api/src/lib.rs @@ -120,6 +120,13 @@ pub use tinymemory_bus::{ capabilities, chunks, composio, error, evidence, goals, graph, health, learning, namespace, operations, recall, tool_memory, tree, types, version, wire, }; +// `chrono` rides the same rule for the same reason. Two trait methods on +// `provider::MemoryTree` — `runtime_buffer_write` and `runtime_summarize` — take +// a `DateTime` in their signature, so *implementing* the contract requires +// naming the type, and a driver crate that depends on this one and nothing else +// had no path to it. Forwarding the re-export is what makes "depend on the +// contract alone" true for an implementor rather than only for a caller. +pub use tinymemory_bus::chrono; /// The mandatory-family composition: wrap any [`traits::Memory`] backend as a /// complete [`provider::MemoryProvider`]. /// diff --git a/crates/tinymemory-conformance/src/lib.rs b/crates/tinymemory-conformance/src/lib.rs index 9a66e069..a63a87c1 100644 --- a/crates/tinymemory-conformance/src/lib.rs +++ b/crates/tinymemory-conformance/src/lib.rs @@ -41,11 +41,12 @@ pub mod reference; pub mod suite; +pub use reference::full::{Call, RecordingProvider, FULL_DRIVER_ID}; pub use reference::{InMemoryProvider, REFERENCE_DRIVER_ID}; pub use suite::{ - assert_awkward_content_round_trips, assert_capability_audit, assert_export_cursor_terminates, - assert_export_import_round_trip, assert_forget_is_idempotent, assert_kv_round_trip, - assert_list_filters_narrow, assert_namespaces_are_isolated, + assert_awkward_content_round_trips, assert_capability_audit, assert_documents_round_trip, + assert_export_cursor_terminates, assert_export_import_round_trip, assert_forget_is_idempotent, + assert_kv_round_trip, assert_list_filters_narrow, assert_namespaces_are_isolated, assert_namespaces_preserve_their_section, assert_provider, assert_recall_respects_limit_and_namespace, assert_store_get_round_trip, assert_taint_is_preserved, assert_upsert_replaces_rather_than_duplicates, diff --git a/crates/tinymemory-conformance/src/reference/full.rs b/crates/tinymemory-conformance/src/reference/full.rs new file mode 100644 index 00000000..65005c47 --- /dev/null +++ b/crates/tinymemory-conformance/src/reference/full.rs @@ -0,0 +1,1586 @@ +// ported from openhuman src/openhuman/memory/guard/test_support_part_0{1,2,3}.rs +use std::sync::Mutex; +use tinymemory_api::provider::operations::{ + MemoryAnswer, MemoryConversationIngest, MemoryDocumentIngest, MemoryEventIngest, + MemoryLearningIngest, +}; + +use async_trait::async_trait; +use tinymemory_api::capabilities::Capabilities; +use tinymemory_api::chunks::Chunk; +use tinymemory_api::error::MemoryError; +use tinymemory_api::goals::GoalsDoc; +use tinymemory_api::health::MemoryHealth; +use tinymemory_api::provider::sessions::{ + CodingSessionIngestReport, CodingSessionIngestRequest, CodingSessionSource, +}; +use tinymemory_api::provider::sync::{ + RawArchiveCoverage, RawRebuildOutcome, SourceSyncState, SourceSyncStatus, SyncAuditEntry, + SyncRunOutcome, +}; +use tinymemory_api::provider::types::{ + DiffReport, EntityHit, ExportPage, ExportRecord, ImportOutcome, IngestItem, IngestOutcome, + MaintenanceReport, SnapshotRef, SourceItem, SourceScope, +}; +use tinymemory_api::provider::{ + AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, CoverWindowQuery, EntityMatch, + EpisodicEvent, FacetType, FastRetrieveQuery, MemoryChunks, MemoryCodingSessions, MemoryCore, + MemoryDiff, MemoryDocuments, MemoryEntities, MemoryEpisodic, MemoryGoals, MemoryGraph, + MemoryIngest, MemoryMaintenance, MemoryPeople, MemoryPortability, MemoryProfile, + MemoryProvider, MemoryRecall, MemoryRetrieval, MemoryScoring, MemorySourceSink, + MemorySourceSync, MemoryToolMemory, MemoryTree, PersonHandle, PersonInteraction, PersonRecord, + PersonScore, ProfileFacet, RankedPerson, ResolvedPerson, RetrievalHit, RetrievalResponse, + SourceRetrievalQuery, UserState, +}; +use tinymemory_api::recall::OwnedRecallOpts; +use tinymemory_api::tool_memory::ToolMemoryRule; +use tinymemory_api::tree::{IngestRequest, QueryResult, TreeStatus}; +use tinymemory_api::types::{ + GraphRelationRecord, MemoryCategory, MemoryEntry, MemoryKvRecord, MemoryTaint, + NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, + StoredMemoryDocument, +}; + +/// The driver id [`RecordingProvider`] binds under. +pub const FULL_DRIVER_ID: &str = "recording"; + +/// Locks a fake's state, recovering from a poisoned mutex rather than failing. +/// +/// A poisoned lock means an earlier caller panicked while holding it. In a +/// storage engine that is a reason to refuse the call, and the reference driver +/// does exactly that. Here it is not: this driver's state is a call log and a +/// couple of maps, a panicking test has already failed, and turning its +/// neighbour's lock into a second, unrelated failure only obscures which test +/// broke. `into_inner` keeps the first failure the only one. +fn lock(cell: &Mutex) -> std::sync::MutexGuard<'_, T> { + cell.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// One call that reached the driver. +#[derive(Debug, Clone, PartialEq)] +pub struct Call { + /// The family-qualified method name, e.g. `chunks.list_chunks`. + pub method: String, + /// Content the driver was handed, when the method carries any. + pub content: Option, + /// Provenance the driver was handed, when the method carries any. + pub taint: Option, + /// Whether the method received a `Some(scope)`. + pub scoped: Option, +} + +/// The scope's allow list rendered for assertions, sorted for determinism. +fn rendered_scope(scope: Option<&SourceScope>) -> Option { + scope.map(|s| { + let mut allow = s.allow.clone(); + allow.sort(); + allow.join(",") + }) +} + +impl Call { + fn plain(method: &str) -> Self { + Self { + method: method.into(), + content: None, + taint: None, + scoped: None, + } + } +} + +/// A provider that records and answers with empties. +pub struct RecordingProvider { + calls: Mutex>, + /// What `recall` returns, so budget tests can drive a known result set. + recall_result: Mutex>, + /// What `fast_retrieve` returns, so the auto-recall lane can be driven + /// through a real guard with known hits. + fast_retrieve_result: Mutex, + /// What `recall_namespace_scored` returns, so the vector-floored recall + /// paths (Lane B, the contradiction check) can be driven with known scores. + namespace_hits: Mutex>, + /// What `namespaces` returns, so a namespace can look populated (Lane B + /// asks for the count before it pays for an embed) without a real store. + namespace_summaries: Mutex>, + /// Documents written through [`MemoryDocuments::put_document`], keyed the + /// way the contract upserts them. + documents: Mutex>, + /// Rows written through [`MemoryGraph::kv_put`]. + kv: Mutex, String), MemoryKvRecord>>, +} + +impl Default for RecordingProvider { + fn default() -> Self { + Self::new() + } +} + +impl RecordingProvider { + /// Builds a driver with an empty call log and empty canned answers. + #[must_use] + pub fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + recall_result: Mutex::new(Vec::new()), + fast_retrieve_result: Mutex::new(RetrievalResponse::default()), + namespace_hits: Mutex::new(Vec::new()), + namespace_summaries: Mutex::new(Vec::new()), + documents: Mutex::new(std::collections::HashMap::new()), + kv: Mutex::new(std::collections::HashMap::new()), + } + } + + /// Sets what [`MemoryRecall::recall`] returns. + #[must_use] + pub fn with_recall_result(self, entries: Vec) -> Self { + *lock(&self.recall_result) = entries; + self + } + + /// Sets what [`MemoryRetrieval::fast_retrieve`] returns. + #[must_use] + pub fn with_fast_retrieve_result(self, response: RetrievalResponse) -> Self { + *lock(&self.fast_retrieve_result) = response; + self + } + + /// Sets what [`MemoryRetrieval::recall_namespace_scored`] returns. + #[must_use] + pub fn with_namespace_hits(self, hits: Vec) -> Self { + *lock(&self.namespace_hits) = hits; + self + } + + /// Sets what [`MemoryCore::namespaces`] returns. + #[must_use] + pub fn with_namespace_summaries(self, summaries: Vec) -> Self { + *lock(&self.namespace_summaries) = summaries; + self + } + + fn record(&self, call: Call) { + lock(&self.calls).push(call); + } + + /// Every call this driver has been handed, in order. + #[must_use] + pub fn calls(&self) -> Vec { + lock(&self.calls).clone() + } + + /// How many calls this driver has been handed. + #[must_use] + pub fn call_count(&self) -> usize { + lock(&self.calls).len() + } + + /// The single recorded call, panicking when there is not exactly one. + pub fn only_call(&self) -> Call { + let mut calls = self.calls(); + assert_eq!( + calls.len(), + 1, + "expected exactly one driver call: {calls:?}" + ); + calls.remove(0) + } +} + +/// An [`ExportRecord`] fixture. +pub fn export_record(taint: MemoryTaint) -> ExportRecord { + ExportRecord { + kind: "entry".into(), + id: "r1".into(), + namespace: Some("ns".into()), + taint, + payload: serde_json::Value::Null, + } +} + +/// A [`MemoryEntry`] fixture. +pub fn entry(content: &str) -> MemoryEntry { + MemoryEntry { + id: "id".into(), + key: "key".into(), + content: content.into(), + namespace: Some("ns".into()), + category: MemoryCategory::Core, + timestamp: "2026-01-01T00:00:00Z".into(), + session_id: None, + score: None, + taint: MemoryTaint::Internal, + } +} + +/// A [`TreeStatus`] fixture. +fn tree_status(namespace: &str) -> TreeStatus { + TreeStatus { + namespace: namespace.to_string(), + total_nodes: 0, + depth: 0, + oldest_entry: None, + newest_entry: None, + last_run_at: None, + } +} + +/// A [`NamespaceDocumentInput`] fixture. +pub fn document(content: &str, taint: MemoryTaint) -> NamespaceDocumentInput { + NamespaceDocumentInput { + namespace: "ns".into(), + key: "k".into(), + title: "t".into(), + content: content.into(), + source_type: "chat".into(), + priority: "normal".into(), + tags: vec![], + metadata: serde_json::Value::Null, + category: "core".into(), + session_id: None, + document_id: None, + taint, + } +} + +#[async_trait] +impl MemoryCore for RecordingProvider { + async fn store( + &self, + _namespace: &str, + _key: &str, + content: &str, + _category: MemoryCategory, + _session_id: Option<&str>, + taint: MemoryTaint, + ) -> Result<(), MemoryError> { + self.record(Call { + method: "core.store".into(), + content: Some(content.to_string()), + taint: Some(taint), + scoped: None, + }); + Ok(()) + } + + async fn get(&self, _namespace: &str, _key: &str) -> Result, MemoryError> { + self.record(Call::plain("core.get")); + Ok(None) + } + + async fn forget(&self, _namespace: &str, _key: &str) -> Result { + self.record(Call::plain("core.forget")); + Ok(false) + } + + async fn list( + &self, + _namespace: Option<&str>, + _category: Option<&MemoryCategory>, + _session_id: Option<&str>, + ) -> Result, MemoryError> { + self.record(Call::plain("core.list")); + Ok(vec![]) + } + + async fn namespaces(&self) -> Result, MemoryError> { + self.record(Call::plain("core.namespaces")); + Ok(lock(&self.namespace_summaries).clone()) + } +} + +#[async_trait] +impl MemoryRecall for RecordingProvider { + async fn recall( + &self, + query: &str, + _limit: usize, + _opts: &OwnedRecallOpts, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.record(Call { + method: "recall.recall".into(), + content: Some(query.to_string()), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(lock(&self.recall_result).clone()) + } +} + +#[async_trait] +impl MemoryPortability for RecordingProvider { + // A cursor this driver never issued is refused rather than silently + // restarting the export, which would duplicate rows for a caller paging + // through. The fake issues no cursors at all, so *every* cursor is + // unrecognised — which is exactly the state the contract's rule is about, + // and the port arrived here answering an empty page instead. + async fn export_page( + &self, + cursor: Option<&str>, + _limit: usize, + ) -> Result { + self.record(Call::plain("portability.export_page")); + if let Some(cursor) = cursor { + return Err(MemoryError::Invalid(format!( + "unrecognised export cursor: {cursor}" + ))); + } + Ok(ExportPage::default()) + } + + async fn import_records( + &self, + records: Vec, + ) -> Result { + self.record(Call { + method: "portability.import_records".into(), + content: None, + taint: records.first().map(|r| r.taint), + scoped: None, + }); + Ok(ImportOutcome::default()) + } +} + +#[async_trait] +impl MemoryIngest for RecordingProvider { + async fn ingest_document(&self, item: IngestItem) -> Result { + self.record(Call { + method: "ingest.ingest_document".into(), + content: Some(item.content), + taint: Some(item.taint), + scoped: None, + }); + Ok(IngestOutcome::default()) + } + + async fn ingest_chat(&self, messages: Vec) -> Result { + self.record(Call { + method: "ingest.ingest_chat".into(), + content: messages.first().map(|m| m.content.clone()), + taint: messages.first().map(|m| m.taint), + scoped: None, + }); + Ok(IngestOutcome::default()) + } +} + +#[async_trait] +impl MemoryDocuments for RecordingProvider { + async fn put_document(&self, input: NamespaceDocumentInput) -> Result { + self.record(Call { + method: "documents.put_document".into(), + content: Some(input.content.clone()), + taint: Some(input.taint), + scoped: None, + }); + let document_id = input.document_id.clone().unwrap_or_else(|| "doc".into()); + let stored = StoredMemoryDocument { + document_id: document_id.clone(), + namespace: input.namespace.clone(), + key: input.key.clone(), + title: input.title, + content: input.content, + source_type: input.source_type, + priority: input.priority, + tags: input.tags, + metadata: input.metadata, + category: input.category, + session_id: input.session_id, + created_at: 0.0, + updated_at: 0.0, + markdown_rel_path: String::new(), + taint: input.taint, + }; + lock(&self.documents).insert((input.namespace, input.key), stored); + Ok(document_id) + } + + async fn get_document( + &self, + namespace: &str, + key: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("documents.get_document")); + Ok(lock(&self.documents) + .get(&(namespace.to_string(), key.to_string())) + .cloned()) + } + + async fn list_documents( + &self, + _namespace: Option<&str>, + ) -> Result { + self.record(Call::plain("documents.list_documents")); + Ok(serde_json::json!({"documents": []})) + } + + async fn list_namespaces(&self) -> Result, MemoryError> { + self.record(Call::plain("documents.list_namespaces")); + let mut seen: Vec = lock(&self.documents) + .keys() + .map(|(ns, _)| ns.clone()) + .collect(); + seen.sort_unstable(); + seen.dedup(); + Ok(seen) + } + + async fn delete_document( + &self, + namespace: &str, + document_id: &str, + ) -> Result { + self.record(Call::plain("documents.delete_document")); + let mut docs = lock(&self.documents); + let victim = docs + .iter() + .find(|((ns, _), doc)| ns == namespace && doc.document_id == document_id) + .map(|(k, _)| k.clone()); + let deleted = victim.is_some_and(|k| docs.remove(&k).is_some()); + Ok(serde_json::json!({ "deleted": deleted })) + } + + async fn clear_namespace(&self, namespace: &str) -> Result<(), MemoryError> { + self.record(Call::plain("documents.clear_namespace")); + lock(&self.documents).retain(|(ns, _), _| ns != namespace); + Ok(()) + } + + async fn query_documents( + &self, + namespace: &str, + query: &str, + _limit: usize, + ) -> Result { + self.record(Call { + method: "documents.query_documents".into(), + content: Some(query.to_string()), + taint: None, + scoped: None, + }); + Ok(NamespaceRetrievalContext { + namespace: namespace.to_string(), + query: Some(query.to_string()), + context_text: String::new(), + hits: vec![], + }) + } + + async fn recall_documents( + &self, + namespace: &str, + _limit: usize, + ) -> Result { + self.record(Call::plain("documents.recall_documents")); + Ok(NamespaceRetrievalContext { + namespace: namespace.to_string(), + query: None, + context_text: String::new(), + hits: vec![], + }) + } +} + +#[async_trait] +impl MemoryTree for RecordingProvider { + async fn append(&self, request: IngestRequest) -> Result<(), MemoryError> { + self.record(Call { + method: "tree.append".into(), + content: Some(request.content), + taint: None, + scoped: None, + }); + Ok(()) + } + + async fn query_source( + &self, + _namespace: &str, + _source_id: &str, + _limit: usize, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.record(Call { + method: "tree.query_source".into(), + // The scope's allow list, rendered so a test can assert which one + // arrived. Sorted because it comes from a `HashSet`. + content: scope.map(|s| { + let mut allow = s.allow.clone(); + allow.sort(); + allow.join(",") + }), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(vec![]) + } + + async fn drill_down( + &self, + _namespace: &str, + _node_id: &str, + ) -> Result { + self.record(Call::plain("tree.drill_down")); + Err(MemoryError::NotFound("node".into())) + } + + async fn seal(&self, namespace: &str) -> Result { + self.record(Call::plain("tree.seal")); + Ok(tree_status(namespace)) + } + + async fn cascade(&self, namespace: &str) -> Result { + self.record(Call::plain("tree.cascade")); + Ok(tree_status(namespace)) + } + + /// Records the folded bodies as one blob, so a redaction test can assert on + /// what the driver's summariser would have been handed. + async fn summarise( + &self, + inputs: &[tinymemory_api::provider::content::SummaryInput], + _context: &tinymemory_api::provider::content::SummaryContext, + ) -> Result { + self.record(Call { + method: "tree.summarise".into(), + content: Some( + inputs + .iter() + .map(|input| input.content.clone()) + .collect::>() + .join("|"), + ), + taint: None, + scoped: None, + }); + Ok(Default::default()) + } + + async fn root_summaries_with_caps( + &self, + _per_namespace_cap: usize, + _total_cap: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("tree.root_summaries_with_caps")); + Ok(Vec::new()) + } + + // ── The runtime-tree and flavour doors ────────────────────────────────── + // + // Overridden for the same reason `summarise` and `root_summaries_with_caps` + // are: each is defaulted on the trait, so a `GuardedTree` that forgot to + // forward one still compiles and answers `Unsupported`. A driver that + // *succeeds* here is what makes `the_defaulted_doors_are_forwarded_rather_than_refused` + // able to tell the two apart. + + /// Records the buffered body, so a redaction test can assert what the + /// driver's buffer would have been handed — [`Self::append`]'s twin. + async fn runtime_buffer_write( + &self, + _namespace: &str, + content: &str, + _timestamp: tinymemory_api::chrono::DateTime, + _metadata: Option, + ) -> Result { + self.record(Call { + method: "tree.runtime_buffer_write".into(), + content: Some(content.to_string()), + taint: None, + scoped: None, + }); + Ok("/buffer/2026/01/01/00.md".to_string()) + } + + async fn runtime_read_node( + &self, + _namespace: &str, + _node_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("tree.runtime_read_node")); + Ok(None) + } + + async fn runtime_read_children( + &self, + _namespace: &str, + _parent_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("tree.runtime_read_children")); + Ok(Vec::new()) + } + + async fn runtime_tree_status(&self, namespace: &str) -> Result { + self.record(Call::plain("tree.runtime_tree_status")); + Ok(tree_status(namespace)) + } + + async fn runtime_summarize( + &self, + _namespace: &str, + _timestamp: tinymemory_api::chrono::DateTime, + ) -> Result, MemoryError> { + self.record(Call::plain("tree.runtime_summarize")); + Ok(None) + } + + async fn runtime_rebuild(&self, namespace: &str) -> Result { + self.record(Call::plain("tree.runtime_rebuild")); + Ok(tree_status(namespace)) + } + + async fn flavour_profile(&self, _scope: &str) -> Result, MemoryError> { + self.record(Call::plain("tree.flavour_profile")); + Ok(None) + } +} + +#[async_trait] +impl MemoryEntities for RecordingProvider { + async fn entities( + &self, + _namespace: &str, + _query: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("entities.entities")); + Ok(vec![]) + } + + async fn entity_edges( + &self, + _namespace: &str, + _entity_id: &str, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("entities.entity_edges")); + Ok(vec![]) + } + + async fn touch_entities( + &self, + _namespace: &str, + _entity_ids: &[String], + ) -> Result<(), MemoryError> { + self.record(Call::plain("entities.touch_entities")); + Ok(()) + } +} + +#[async_trait] +impl MemoryGraph for RecordingProvider { + async fn kv_get( + &self, + _namespace: Option<&str>, + _key: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("graph.kv_get")); + Ok(lock(&self.kv) + .get(&(_namespace.map(str::to_string), _key.to_string())) + .cloned()) + } + + async fn kv_put( + &self, + namespace: Option<&str>, + key: &str, + value: serde_json::Value, + ) -> Result<(), MemoryError> { + self.record(Call { + method: "graph.kv_put".into(), + content: Some(value.to_string()), + taint: None, + scoped: None, + }); + let owned_ns = namespace.map(str::to_string); + lock(&self.kv).insert( + (owned_ns.clone(), key.to_string()), + MemoryKvRecord { + namespace: owned_ns, + key: key.to_string(), + value, + updated_at: 0.0, + }, + ); + Ok(()) + } + + async fn kv_delete(&self, namespace: Option<&str>, key: &str) -> Result { + self.record(Call::plain("graph.kv_delete")); + Ok(lock(&self.kv) + .remove(&(namespace.map(str::to_string), key.to_string())) + .is_some()) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("graph.kv_list")); + let want_ns = _namespace.map(str::to_string); + let mut rows: Vec = lock(&self.kv) + .iter() + .filter(|((ns, key), _)| *ns == want_ns && _prefix.is_none_or(|p| key.starts_with(p))) + .map(|(_, record)| record.clone()) + .collect(); + rows.sort_by(|a, b| a.key.cmp(&b.key)); + rows.truncate(_limit); + Ok(rows) + } + + async fn relations( + &self, + _namespace: Option<&str>, + _subject: Option<&str>, + _predicate: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("graph.relations")); + Ok(vec![]) + } + + async fn put_relation(&self, _relation: GraphRelationRecord) -> Result<(), MemoryError> { + self.record(Call::plain("graph.put_relation")); + Ok(()) + } +} + +#[async_trait] +impl MemoryDiff for RecordingProvider { + async fn capture_snapshot(&self, _source_id: &str) -> Result { + self.record(Call::plain("diff.capture_snapshot")); + Err(MemoryError::NotFound("source".into())) + } + + async fn snapshots( + &self, + _source_id: &str, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("diff.snapshots")); + Ok(vec![]) + } + + async fn diff( + &self, + _source_id: &str, + _from: Option<&str>, + _to: &str, + ) -> Result { + self.record(Call::plain("diff.diff")); + Err(MemoryError::NotFound("snapshot".into())) + } +} + +#[async_trait] +impl MemoryGoals for RecordingProvider { + async fn goals(&self) -> Result { + self.record(Call::plain("goals.goals")); + Ok(GoalsDoc::default()) + } + + async fn set_goals(&self, _goals: GoalsDoc) -> Result<(), MemoryError> { + self.record(Call::plain("goals.set_goals")); + Ok(()) + } +} + +#[async_trait] +impl MemoryToolMemory for RecordingProvider { + async fn tool_rules(&self, _tool_name: &str) -> Result, MemoryError> { + self.record(Call::plain("tool_memory.tool_rules")); + Ok(vec![]) + } + + async fn put_tool_rule(&self, _rule: ToolMemoryRule) -> Result<(), MemoryError> { + self.record(Call::plain("tool_memory.put_tool_rule")); + Ok(()) + } + + async fn delete_tool_rule( + &self, + _tool_name: &str, + _rule_id: &str, + ) -> Result { + self.record(Call::plain("tool_memory.delete_tool_rule")); + Ok(false) + } +} +#[async_trait] +impl MemorySourceSink for RecordingProvider { + async fn accept_source_items( + &self, + _source_id: &str, + _source_kind: &str, + items: Vec, + taint: MemoryTaint, + ) -> Result { + self.record(Call { + method: "sources.accept_source_items".into(), + content: items.first().map(|i| i.content.clone()), + taint: Some(taint), + scoped: None, + }); + Ok(IngestOutcome::default()) + } + + async fn forget_source(&self, _source_id: &str) -> Result { + self.record(Call::plain("sources.forget_source")); + Ok(0) + } +} + +#[async_trait] +impl MemoryMaintenance for RecordingProvider { + async fn reembed(&self) -> Result { + self.record(Call::plain("maintenance.reembed")); + Ok(MaintenanceReport::default()) + } + + async fn compact(&self) -> Result { + self.record(Call::plain("maintenance.compact")); + Ok(MaintenanceReport::default()) + } + + async fn consolidate(&self) -> Result { + self.record(Call::plain("maintenance.consolidate")); + Ok(MaintenanceReport::default()) + } + + async fn doctor(&self) -> Result { + self.record(Call::plain("maintenance.doctor")); + Ok(MaintenanceReport::default()) + } + + async fn diagnose( + &self, + ) -> Result { + self.record(Call::plain("maintenance.diagnose")); + Ok(Default::default()) + } + + async fn degraded_state( + &self, + ) -> Result { + self.record(Call::plain("maintenance.degraded_state")); + Ok(Default::default()) + } +} + +#[async_trait] +impl MemoryProvider for RecordingProvider { + fn driver_id(&self) -> &str { + FULL_DRIVER_ID + } + + fn capabilities(&self) -> Capabilities { + Capabilities::all() + } + + async fn health(&self) -> MemoryHealth { + MemoryHealth::Ready + } + + fn as_ingest(&self) -> Option<&dyn MemoryIngest> { + Some(self) + } + fn as_documents(&self) -> Option<&dyn MemoryDocuments> { + Some(self) + } + fn as_tree(&self) -> Option<&dyn MemoryTree> { + Some(self) + } + fn as_entities(&self) -> Option<&dyn MemoryEntities> { + Some(self) + } + fn as_graph(&self) -> Option<&dyn MemoryGraph> { + Some(self) + } + fn as_diff(&self) -> Option<&dyn MemoryDiff> { + Some(self) + } + fn as_goals(&self) -> Option<&dyn MemoryGoals> { + Some(self) + } + fn as_tool_memory(&self) -> Option<&dyn MemoryToolMemory> { + Some(self) + } + fn as_sources(&self) -> Option<&dyn MemorySourceSink> { + Some(self) + } + fn as_maintenance(&self) -> Option<&dyn MemoryMaintenance> { + Some(self) + } + fn as_people(&self) -> Option<&dyn MemoryPeople> { + Some(self) + } + fn as_chunks(&self) -> Option<&dyn MemoryChunks> { + Some(self) + } + fn as_retrieval(&self) -> Option<&dyn MemoryRetrieval> { + Some(self) + } + fn as_profile(&self) -> Option<&dyn MemoryProfile> { + Some(self) + } + fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> { + Some(self) + } + fn as_source_sync(&self) -> Option<&dyn MemorySourceSync> { + Some(self) + } + fn as_coding_sessions(&self) -> Option<&dyn MemoryCodingSessions> { + Some(self) + } + fn as_scoring(&self) -> Option<&dyn MemoryScoring> { + Some(self) + } + fn as_document_ingest(&self) -> Option<&dyn MemoryDocumentIngest> { + Some(self) + } + fn as_conversation_ingest(&self) -> Option<&dyn MemoryConversationIngest> { + Some(self) + } + fn as_learning_ingest(&self) -> Option<&dyn MemoryLearningIngest> { + Some(self) + } + fn as_event_ingest(&self) -> Option<&dyn MemoryEventIngest> { + Some(self) + } + fn as_answer(&self) -> Option<&dyn MemoryAnswer> { + Some(self) + } +} + +// The two families tinymemory v1.7.0 added. `capabilities()` above answers +// `Capabilities::all()`, so a driver that advertises them and then hands back +// `None` from the accessor is exactly the inconsistency `audit_provider` +// exists to catch — the recorder has to serve them to stay honest. + +#[async_trait] +impl MemorySourceSync for RecordingProvider { + async fn run_connection_sync( + &self, + toolkit: &str, + connection_id: &str, + ) -> Result { + self.record(Call::plain("source_sync.run_connection_sync")); + let _ = (toolkit, connection_id); + Ok(SyncRunOutcome::default()) + } + async fn source_sync_state( + &self, + toolkit: &str, + connection_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("source_sync.source_sync_state")); + let _ = (toolkit, connection_id); + Ok(None) + } + async fn sync_audit_log( + &self, + _limit: Option, + ) -> Result, MemoryError> { + self.record(Call::plain("source_sync.sync_audit_log")); + Ok(Vec::new()) + } + async fn estimate_sync_cost_usd( + &self, + _input_tokens: u64, + _output_tokens: u64, + ) -> Result { + self.record(Call::plain("source_sync.estimate_sync_cost_usd")); + Ok(0.0) + } + async fn sync_statuses(&self) -> Result, MemoryError> { + self.record(Call::plain("source_sync.sync_statuses")); + Ok(Vec::new()) + } + async fn raw_archive_coverage( + &self, + tree_scope: &str, + archive_source_id: &str, + ) -> Result { + self.record(Call::plain("source_sync.raw_archive_coverage")); + let _ = (tree_scope, archive_source_id); + Ok(RawArchiveCoverage::default()) + } + async fn rebuild_from_raw_archive( + &self, + tree_scope: &str, + archive_source_id: &str, + ) -> Result { + self.record(Call::plain("source_sync.rebuild_from_raw_archive")); + let _ = (tree_scope, archive_source_id); + Ok(RawRebuildOutcome::default()) + } +} + +#[async_trait] +impl MemoryCodingSessions for RecordingProvider { + async fn coding_session_status(&self) -> Result, MemoryError> { + self.record(Call::plain("coding_sessions.coding_session_status")); + Ok(Vec::new()) + } + async fn ingest_coding_sessions( + &self, + _request: CodingSessionIngestRequest, + ) -> Result { + self.record(Call::plain("coding_sessions.ingest_coding_sessions")); + Ok(CodingSessionIngestReport::default()) + } +} + +#[async_trait] +impl MemoryEpisodic for RecordingProvider { + async fn insert_turn( + &self, + turn: &tinymemory_api::provider::episodic::EpisodicTurn, + ) -> Result { + // Records the turn text, so a guard that failed to redact one would be + // visible here rather than only in a live store. + self.record(Call { + method: "episodic.insert_turn".into(), + content: Some(turn.content.clone()), + taint: None, + scoped: None, + }); + Ok(1) + } + + async fn session_turns( + &self, + _session_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("episodic.session_turns")); + Ok(vec![]) + } + + async fn open_segment( + &self, + _session_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("episodic.open_segment")); + Ok(None) + } + + async fn create_segment( + &self, + _segment_id: &str, + _session_id: &str, + _namespace: &str, + _start_episodic_id: i64, + _start_seq: Option, + _start_timestamp: f64, + _now: f64, + ) -> Result<(), MemoryError> { + self.record(Call::plain("episodic.create_segment")); + Ok(()) + } + + async fn append_turn( + &self, + _segment_id: &str, + _episodic_id: i64, + _seq: Option, + _timestamp: f64, + _now: f64, + ) -> Result<(), MemoryError> { + self.record(Call::plain("episodic.append_turn")); + Ok(()) + } + + async fn close_segment(&self, _segment_id: &str, _now: f64) -> Result<(), MemoryError> { + self.record(Call::plain("episodic.close_segment")); + Ok(()) + } + + async fn insert_event(&self, event: &EpisodicEvent) -> Result<(), MemoryError> { + // Records the event text for the same reason `insert_turn` does: a guard + // that stopped redacting one would otherwise be invisible to every test, + // and the redaction on this path has already been missing once. + self.record(Call { + method: "episodic.insert_event".into(), + content: Some(event.content.clone()), + taint: None, + scoped: None, + }); + Ok(()) + } + + async fn set_segment_summary( + &self, + _segment_id: &str, + summary: &str, + _now: f64, + ) -> Result<(), MemoryError> { + self.record(Call { + method: "episodic.set_segment_summary".into(), + content: Some(summary.to_string()), + taint: None, + scoped: None, + }); + Ok(()) + } + + async fn upsert_segment_embedding( + &self, + _segment_id: &str, + _model_signature: &str, + _embedding: &[f32], + _created_at: f64, + ) -> Result<(), MemoryError> { + self.record(Call::plain("episodic.upsert_segment_embedding")); + Ok(()) + } +} +#[async_trait] +impl MemoryProfile for RecordingProvider { + async fn list_active_facets(&self) -> Result, MemoryError> { + self.record(Call::plain("profile.list_active_facets")); + Ok(vec![]) + } + async fn list_all_facets(&self) -> Result, MemoryError> { + self.record(Call::plain("profile.list_all_facets")); + Ok(vec![]) + } + async fn get_facet(&self, _key: &str) -> Result, MemoryError> { + self.record(Call::plain("profile.get_facet")); + Ok(None) + } + async fn facets_by_type( + &self, + _facet_type: FacetType, + ) -> Result, MemoryError> { + self.record(Call::plain("profile.facets_by_type")); + Ok(vec![]) + } + async fn upsert_facet(&self, _facet: &ProfileFacet) -> Result<(), MemoryError> { + self.record(Call::plain("profile.upsert_facet")); + Ok(()) + } + async fn upsert_provider_facet( + &self, + _facet_id: &str, + _facet_type: FacetType, + _key: &str, + _value: &str, + _confidence: f64, + _segment_id: Option<&str>, + _observed_at: f64, + ) -> Result<(), MemoryError> { + self.record(Call::plain("profile.upsert_provider_facet")); + Ok(()) + } + async fn set_facet_user_state( + &self, + _key: &str, + _user_state: UserState, + ) -> Result { + self.record(Call::plain("profile.set_facet_user_state")); + Ok(false) + } + async fn delete_facet(&self, _key: &str) -> Result { + self.record(Call::plain("profile.delete_facet")); + Ok(false) + } + async fn delete_facet_by_id(&self, _facet_id: &str) -> Result { + self.record(Call::plain("profile.delete_facet_by_id")); + Ok(false) + } + async fn drop_facets_below(&self, _threshold: f64) -> Result { + self.record(Call::plain("profile.drop_facets_below")); + Ok(0) + } + async fn workflow_identity_matches(&self, _pattern: &str, _value: &str) -> bool { + self.record(Call::plain("profile.workflow_identity_matches")); + false + } +} + +#[async_trait] +impl MemoryChunks for RecordingProvider { + async fn list_chunks( + &self, + _query: &ChunkQuery, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.record(Call { + method: "chunks.list_chunks".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(vec![]) + } + + async fn get_chunk(&self, _chunk_id: &str) -> Result, MemoryError> { + self.record(Call::plain("chunks.get_chunk")); + Ok(None) + } + + async fn chunk_detail(&self, _chunk_id: &str) -> Result, MemoryError> { + self.record(Call::plain("chunks.chunk_detail")); + Ok(None) + } + + async fn storage_kinds(&self) -> Result, MemoryError> { + self.record(Call::plain("chunks.storage_kinds")); + Ok(vec![]) + } + + async fn chunk_embeddings( + &self, + _chunk_ids: &[String], + _model_signature: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("chunks.chunk_embeddings")); + Ok(vec![]) + } + + async fn chunk_score( + &self, + _chunk_id: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("chunks.chunk_score")); + Ok(None) + } + + async fn source_ingest_status( + &self, + _source_prefixes: &[tinymemory_api::provider::chunks::SourceIngestQuery], + ) -> Result, MemoryError> { + self.record(Call::plain("chunks.source_ingest_status")); + Ok(vec![]) + } +} + +#[async_trait] +impl MemoryRetrieval for RecordingProvider { + async fn fast_retrieve( + &self, + _query: &str, + _options: FastRetrieveQuery, + scope: Option<&SourceScope>, + ) -> Result { + self.record(Call { + method: "retrieval.fast_retrieve".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(lock(&self.fast_retrieve_result).clone()) + } + + async fn cover_window( + &self, + _window: &CoverWindowQuery, + scope: Option<&SourceScope>, + ) -> Result { + self.record(Call { + method: "retrieval.cover_window".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(RetrievalResponse::default()) + } + + async fn retrieve_source( + &self, + _query: &SourceRetrievalQuery, + scope: Option<&SourceScope>, + ) -> Result { + self.record(Call { + method: "retrieval.retrieve_source".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(RetrievalResponse::default()) + } + + async fn retrieve_children( + &self, + _node_id: &str, + _max_depth: u32, + _query: Option<&str>, + _limit: Option, + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.record(Call { + method: "retrieval.retrieve_children".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(vec![]) + } + + async fn retrieve_leaves( + &self, + _chunk_ids: &[String], + scope: Option<&SourceScope>, + ) -> Result, MemoryError> { + self.record(Call { + method: "retrieval.retrieve_leaves".into(), + content: rendered_scope(scope), + taint: None, + scoped: Some(scope.is_some()), + }); + Ok(vec![]) + } + + async fn recall_namespace_scored( + &self, + namespace: &str, + _query: &str, + limit: usize, + _exclude_session_id: Option<&str>, + ) -> Result, MemoryError> { + // Honours the two request parameters a caller can get wrong — the + // namespace it asks for and the page it accepts — and records them, + // so a test can assert both rather than only the content it got. + self.record(Call { + method: "retrieval.recall_namespace_scored".into(), + content: Some(format!("namespace={namespace} limit={limit}")), + taint: None, + scoped: None, + }); + Ok(lock(&self.namespace_hits) + .iter() + .filter(|hit| hit.namespace == namespace) + .take(limit) + .cloned() + .collect()) + } + + async fn recall_namespace_recent( + &self, + _namespace: &str, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("retrieval.recall_namespace_recent")); + Ok(vec![]) + } + + async fn search_entities( + &self, + _query: &str, + _kinds: Option<&[String]>, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("retrieval.search_entities")); + Ok(vec![]) + } +} + +#[async_trait] +impl MemoryPeople for RecordingProvider { + async fn list_people(&self, _limit: Option) -> Result, MemoryError> { + self.record(Call::plain("people.list_people")); + Ok(vec![]) + } + + async fn get_person(&self, _person_id: &str) -> Result, MemoryError> { + self.record(Call::plain("people.get_person")); + Ok(None) + } + + async fn resolve_handle( + &self, + _handle: &PersonHandle, + _create_if_missing: bool, + ) -> Result, MemoryError> { + self.record(Call::plain("people.resolve_handle")); + Ok(None) + } + + async fn add_handle_alias( + &self, + _person_id: &str, + _handle: &PersonHandle, + ) -> Result<(), MemoryError> { + self.record(Call::plain("people.add_handle_alias")); + Ok(()) + } + + async fn score_person(&self, _person_id: &str) -> Result, MemoryError> { + self.record(Call::plain("people.score_person")); + Ok(None) + } + + async fn record_interaction( + &self, + _interaction: &PersonInteraction, + ) -> Result<(), MemoryError> { + self.record(Call::plain("people.record_interaction")); + Ok(()) + } + + async fn seed_from_address_book(&self) -> Result { + self.record(Call::plain("people.seed_from_address_book")); + Ok(AddressBookSeedOutcome::default()) + } +} + +#[async_trait] +impl MemoryScoring for RecordingProvider { + async fn extract_entities(&self, query: &str) -> Result, MemoryError> { + self.record(Call { + method: "scoring.extract_entities".into(), + content: Some(query.to_string()), + taint: None, + scoped: None, + }); + Ok(Vec::new()) + } + + async fn embed_text(&self, text: &str) -> Result, MemoryError> { + self.record(Call { + method: "scoring.embed_text".into(), + content: Some(text.to_string()), + taint: None, + scoped: None, + }); + Ok(Vec::new()) + } + + async fn embedder_slug(&self) -> Result { + self.record(Call::plain("scoring.embedder_slug")); + Ok(String::new()) + } +} + +// ── The v1.13.7 typed-ingestion round + Answer ────────────────────────────── +// Same contract as every family above: `capabilities()` answers all(), so the +// audit demands a live accessor and a recording impl for each. + +#[async_trait] +impl MemoryDocumentIngest for RecordingProvider { + async fn ingest_document(&self, document: IngestItem) -> Result { + self.record(Call { + method: "document_ingest.ingest_document".into(), + content: Some(document.content), + taint: Some(document.taint), + scoped: None, + }); + Ok(IngestOutcome::default()) + } +} + +#[async_trait] +impl MemoryConversationIngest for RecordingProvider { + async fn ingest_conversation( + &self, + messages: Vec, + ) -> Result { + for message in messages { + self.record(Call { + method: "conversation_ingest.ingest_conversation".into(), + content: Some(message.content), + taint: Some(message.taint), + scoped: None, + }); + } + Ok(IngestOutcome::default()) + } +} + +#[async_trait] +impl MemoryLearningIngest for RecordingProvider { + async fn ingest_learning( + &self, + _learning: tinymemory_api::learning::LearningCandidate, + ) -> Result { + self.record(Call { + method: "learning_ingest.ingest_learning".into(), + content: None, + taint: None, + scoped: None, + }); + Ok(IngestOutcome::default()) + } +} + +#[async_trait] +impl MemoryEventIngest for RecordingProvider { + async fn ingest_event( + &self, + _event: tinymemory_api::provider::operations::RawMemoryEvent, + ) -> Result { + self.record(Call { + method: "event_ingest.ingest_event".into(), + content: None, + taint: None, + scoped: None, + }); + Ok(IngestOutcome::default()) + } +} + +#[async_trait] +impl MemoryAnswer for RecordingProvider { + async fn answer( + &self, + _request: tinymemory_api::provider::operations::AnswerRequest, + ) -> Result { + self.record(Call { + method: "answer.answer".into(), + content: None, + taint: None, + scoped: None, + }); + Ok(tinymemory_api::provider::operations::AnswerResponse { + answer: String::new(), + model: None, + citations: Vec::new(), + steps: Vec::new(), + }) + } +} +// Fixtures for the retrieval family's scored answers. Included into +// `test_support.rs` after the provider parts, so the imports there are in scope. + +/// A [`NamespaceSummary`] saying `namespace` holds `count` entries. +pub fn namespace_summary(namespace: &str, count: usize) -> NamespaceSummary { + NamespaceSummary { + namespace: namespace.into(), + count, + last_updated: None, + } +} + +/// A [`NamespaceMemoryHit`] with only the vector component set — the signal the +/// vector-floored recall paths (Lane B, the contradiction check) filter on. +pub fn namespace_hit( + namespace: &str, + key: &str, + content: &str, + vector_similarity: f64, +) -> NamespaceMemoryHit { + NamespaceMemoryHit { + id: format!("{namespace}/{key}"), + kind: tinymemory_api::types::MemoryItemKind::Kv, + namespace: namespace.into(), + key: key.into(), + title: None, + content: content.into(), + category: "core".into(), + source_type: None, + updated_at: 0.0, + score: vector_similarity, + score_breakdown: tinymemory_api::types::RetrievalScoreBreakdown { + vector_similarity, + ..Default::default() + }, + document_id: None, + chunk_id: None, + supporting_relations: Vec::new(), + taint: MemoryTaint::default(), + } +} diff --git a/crates/tinymemory-conformance/src/reference/mod.rs b/crates/tinymemory-conformance/src/reference/mod.rs index d0eb2c39..146059a0 100644 --- a/crates/tinymemory-conformance/src/reference/mod.rs +++ b/crates/tinymemory-conformance/src/reference/mod.rs @@ -305,3 +305,6 @@ impl MemoryProvider for InMemoryProvider { MemoryHealth::Ready } } + +/// A driver that serves every optional family, for hosts testing above the contract. +pub mod full; diff --git a/crates/tinymemory-conformance/src/suite/mod.rs b/crates/tinymemory-conformance/src/suite/mod.rs index 09c2f285..404335d4 100644 --- a/crates/tinymemory-conformance/src/suite/mod.rs +++ b/crates/tinymemory-conformance/src/suite/mod.rs @@ -28,7 +28,7 @@ use tinymemory_api::error::MemoryError; use tinymemory_api::namespace::Namespace; use tinymemory_api::provider::{audit_provider, ExportRecord, MemoryProvider, SourceScope}; use tinymemory_api::recall::OwnedRecallOpts; -use tinymemory_api::types::{MemoryCategory, MemoryTaint}; +use tinymemory_api::types::{MemoryCategory, MemoryTaint, NamespaceDocumentInput}; /// Runs every assertion in the suite. /// @@ -67,6 +67,7 @@ pub async fn assert_provider(provider: Arc) { assert_export_import_round_trip(p).await; assert_awkward_content_round_trips(p).await; assert_kv_round_trip(p).await; + assert_documents_round_trip(p).await; } /// Whether this driver reads back what it stores. @@ -742,6 +743,108 @@ pub async fn assert_export_cursor_terminates(provider: &dyn MemoryProvider) { ); } +/// A document survives the `(namespace, key)` round trip, and a second write +/// under the same key replaces it. +/// +/// The document tier is not the entry tier, and a driver can get one right +/// while getting the other wrong: entries go through `store`/`get`, documents +/// through `put_document`/`get_document`, and nothing before this checked that +/// the second pair upholds the same upsert rule as the first. A driver that +/// appended instead of replacing would show a host two documents where its user +/// wrote one, and the host cannot tell — it asked by key and got a list back. +/// +/// What is deliberately *not* asserted: `document_id`, `created_at`, +/// `updated_at` and `markdown_rel_path`. Those are the driver's to choose, and +/// an engine that persists markdown legitimately fills the last one where an +/// in-memory driver leaves it empty. +/// +/// # Panics +/// +/// Panics when a written document does not read back, when its fields do not +/// survive, or when a same-key rewrite duplicates rather than replaces. +pub async fn assert_documents_round_trip(provider: &dyn MemoryProvider) { + let who = provider.driver_id(); + let Some(documents) = provider.as_documents() else { + return; + }; + let namespace = ns(provider, "documents"); + let key = "round-trip"; + + let write = |title: &str, content: &str| NamespaceDocumentInput { + namespace: namespace.clone(), + key: key.to_string(), + title: title.to_string(), + content: content.to_string(), + source_type: "conformance".into(), + priority: "normal".into(), + tags: vec!["conformance".into()], + metadata: serde_json::Value::Null, + category: "core".to_string(), + session_id: None, + document_id: None, + taint: MemoryTaint::ExternalSync, + }; + + documents + .put_document(write("first", "the first body")) + .await + .unwrap_or_else(|e| panic!("{who}: put_document failed: {e}")); + + let stored = documents + .get_document(&namespace, key) + .await + .unwrap_or_else(|e| panic!("{who}: get_document failed: {e}")) + .unwrap_or_else(|| { + panic!("{who}: get_document did not find `{key}` right after put_document") + }); + assert_eq!( + stored.content, "the first body", + "{who}: document content did not survive the round trip" + ); + assert_eq!( + stored.namespace, namespace, + "{who}: document came back under a different namespace" + ); + assert_eq!( + stored.key, key, + "{who}: document came back under a different key" + ); + assert_eq!( + stored.taint, + MemoryTaint::ExternalSync, + "{who}: document taint was not preserved — external content has been \ + laundered into internal-trust content" + ); + + // The upsert rule, which is the whole reason the key exists. + documents + .put_document(write("second", "the second body")) + .await + .unwrap_or_else(|e| panic!("{who}: the second put_document failed: {e}")); + let replaced = documents + .get_document(&namespace, key) + .await + .unwrap_or_else(|e| panic!("{who}: get_document after rewrite failed: {e}")) + .unwrap_or_else(|| panic!("{who}: the rewritten document is not readable")); + assert_eq!( + replaced.content, "the second body", + "{who}: a second write under the same key did not replace the first" + ); + + documents + .clear_namespace(&namespace) + .await + .unwrap_or_else(|e| panic!("{who}: clear_namespace failed: {e}")); + let gone = documents + .get_document(&namespace, key) + .await + .unwrap_or_else(|e| panic!("{who}: get_document after clear_namespace failed: {e}")); + assert!( + gone.is_none(), + "{who}: the document is still readable after clear_namespace" + ); +} + /// Unicode, empty, and oversized content survive a round trip. /// /// # Panics diff --git a/crates/tinymemory-conformance/tests/reference_drivers.rs b/crates/tinymemory-conformance/tests/reference_drivers.rs index fe8be6da..aa5d702d 100644 --- a/crates/tinymemory-conformance/tests/reference_drivers.rs +++ b/crates/tinymemory-conformance/tests/reference_drivers.rs @@ -45,3 +45,30 @@ async fn the_reference_driver_advertises_exactly_the_mandatory_families() { assert!(provider.as_graph().is_none()); assert!(provider.as_ingest().is_none()); } + +/// The full driver is the third subject, and it is the one a *host* binds. +/// +/// `InMemoryProvider` proves the assertions are right; `NullMemoryProvider` +/// proves which of them survive a driver that retains nothing. Neither answers +/// the question this driver exists for: a host testing its own layer above the +/// contract needs every optional family reachable, because its handlers ask for +/// them by accessor and take the `None` arm as "unsupported" rather than as +/// "empty". Running the same suite here keeps that convenience honest — a +/// driver that serves 27 families still has to uphold the three mandatory ones. +#[tokio::test] +async fn the_full_driver_conforms() { + assert_provider(Arc::new(tinymemory_conformance::RecordingProvider::new())).await; +} + +/// It advertises everything, which is the opposite of the reference driver's +/// claim and has to stay that way for `audit_provider` to pass: a driver that +/// advertised less than it serves fails the audit just as surely as one that +/// advertises more. +#[tokio::test] +async fn the_full_driver_advertises_every_family() { + let provider = tinymemory_conformance::RecordingProvider::new(); + assert!(provider.as_tree().is_some()); + assert!(provider.as_chunks().is_some()); + assert!(provider.as_documents().is_some()); + assert!(provider.as_retrieval().is_some()); +} diff --git a/crates/tinymemory-core/src/tree/mod.rs b/crates/tinymemory-core/src/tree/mod.rs index 3e26f8ac..5371a4b7 100644 --- a/crates/tinymemory-core/src/tree/mod.rs +++ b/crates/tinymemory-core/src/tree/mod.rs @@ -12,6 +12,8 @@ pub mod nlp; pub mod retrieval; pub mod score; pub mod summarise; +#[cfg(test)] +mod summarise_tests; // `module_inception` is a byproduct of the domain-family reorg: the parent was // renamed from `memory_tree` to `memory/tree`, which shortened it to match this // long-standing inner module. Renaming the inner module would be a real rename diff --git a/crates/tinymemory-core/src/tree/summarise_tests.rs b/crates/tinymemory-core/src/tree/summarise_tests.rs new file mode 100644 index 00000000..ceb98526 --- /dev/null +++ b/crates/tinymemory-core/src/tree/summarise_tests.rs @@ -0,0 +1,80 @@ +//! Behaviour of the deterministic fallback summariser. +//! +//! `summarise` itself needs a chat provider and is covered where the provider +//! seam is. [`fallback_summary`] needs nothing — it is the answer when no model +//! is reachable, which makes it the path a degraded install actually runs, and +//! it had no test of its own on either side of the host boundary. + +use chrono::{TimeZone, Utc}; + +use super::summarise::{fallback_summary, SummaryInput}; + +fn input(id: &str, content: &str, entities: &[&str], topics: &[&str], score: f32) -> SummaryInput { + let at = Utc + .with_ymd_and_hms(2026, 5, 29, 9, 8, 7) + .single() + .expect("a real instant"); + SummaryInput { + id: id.to_string(), + content: content.to_string(), + token_count: 0, + entities: entities.iter().map(|e| (*e).to_string()).collect(), + topics: topics.iter().map(|t| (*t).to_string()).collect(), + time_range_start: at, + time_range_end: at, + score, + } +} + +/// Blank inputs are dropped rather than summarised into empty bullets, and the +/// budget is honoured. +/// +/// The blank input carries an entity and the surviving one carries a topic, so +/// this also pins that the fallback propagates neither. That is easy to get +/// wrong in the direction that matters: carrying a dropped input's entities +/// forward would attribute them to a summary whose text never mentions them. +#[test] +fn a_blank_input_is_dropped_and_the_budget_is_honoured() { + let inputs = vec![ + input("blank", " ", &["ignored"], &[], 0.1), + input( + "long", + &"alpha beta gamma delta epsilon zeta eta theta".repeat(20), + &[], + &["planning"], + 0.9, + ), + ]; + + let out = fallback_summary(&inputs, 8); + + assert!( + out.content.starts_with("— alpha"), + "the blank input was not dropped: {:?}", + out.content + ); + assert!( + out.token_count <= 9, + "a budget of 8 produced {} tokens", + out.token_count + ); + assert!( + out.entities.is_empty(), + "a dropped input's entities were carried into the summary: {:?}", + out.entities + ); + assert!( + out.topics.is_empty(), + "topics were carried into a summary whose text does not mention them: {:?}", + out.topics + ); +} + +/// With nothing to summarise the fallback answers empty rather than a bullet +/// with no content behind it. +#[test] +fn no_inputs_produce_no_summary() { + let out = fallback_summary(&[], 64); + assert!(out.content.is_empty(), "got {:?}", out.content); + assert_eq!(out.token_count, 0); +}