From a211f06656e268a2067450e1fa204756d3633557 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 9 Sep 2026 14:34:22 +0530 Subject: [PATCH 1/5] feat(conformance): carry a full-family driver for host test suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance crate shipped one driver, `InMemoryProvider`, which serves the three mandatory families and leaves every optional accessor at `None`. That is the right shape for a calibration subject and the wrong shape for a host standing up its own memory tests: OpenHuman's fixture binds a driver specifically for the families the null driver cannot serve, so it had to construct a real engine instead, and 133k lines of TinyCortex and tinymemory-core followed it into that host's test build. This adds a second driver beside it rather than widening it. Widening would have deleted the calibration subject — "a failure here means the assertion is wrong, not the driver" only holds while the driver is obvious by inspection, and `the_reference_driver_advertises_exactly_the_mandatory_families` pins that with `caps.len() == 3`. The code is ported from OpenHuman's `memory/guard/test_support_part_0{1,2,3}.rs`, which already implemented all 27 families and named no engine type across 1,493 lines. What did not come with it: four helpers built on that host's `GuardPolicy` and `MemoryGuard`, which are its policy layer and not contract surface. `tinymemory-api` gains one re-export to make this possible. `MemoryTree`'s `runtime_buffer_write` and `runtime_summarize` take a `DateTime` in their signature, so implementing the contract requires naming chrono, and a driver crate depending on the contract alone had no path to it. The bus already re-exports chrono; forwarding it costs no dependency and makes "depend on the contract alone" true for an implementor rather than only for a caller. Refs tinyhumansai/tinymemory#147 --- crates/tinymemory-api/src/lib.rs | 7 + .../src/reference/full.rs | 1513 +++++++++++++++++ .../src/reference/mod.rs | 3 + 3 files changed, 1523 insertions(+) create mode 100644 crates/tinymemory-conformance/src/reference/full.rs 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/reference/full.rs b/crates/tinymemory-conformance/src/reference/full.rs new file mode 100644 index 00000000..154850e5 --- /dev/null +++ b/crates/tinymemory-conformance/src/reference/full.rs @@ -0,0 +1,1513 @@ +// ported from openhuman src/openhuman/memory/guard/test_support_part_0{1,2,3}.rs +use tinymemory_api::provider::operations::{ + MemoryAnswer, MemoryConversationIngest, MemoryDocumentIngest, MemoryEventIngest, + MemoryLearningIngest, +}; +use std::sync::Mutex; + +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, +}; +use async_trait::async_trait; + +/// 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>, +} + +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()), + } + } + + /// Sets what [`MemoryRecall::recall`] returns. + #[must_use] + pub fn with_recall_result(self, entries: Vec) -> Self { + *self.recall_result.lock().unwrap() = entries; + self + } + + /// Sets what [`MemoryRetrieval::fast_retrieve`] returns. + #[must_use] + pub fn with_fast_retrieve_result(self, response: RetrievalResponse) -> Self { + *self.fast_retrieve_result.lock().unwrap() = response; + self + } + + /// Sets what [`MemoryRetrieval::recall_namespace_scored`] returns. + #[must_use] + pub fn with_namespace_hits(self, hits: Vec) -> Self { + *self.namespace_hits.lock().unwrap() = hits; + self + } + + /// Sets what [`MemoryCore::namespaces`] returns. + #[must_use] + pub fn with_namespace_summaries(self, summaries: Vec) -> Self { + *self.namespace_summaries.lock().unwrap() = summaries; + self + } + + fn record(&self, call: Call) { + self.calls.lock().unwrap().push(call); + } + + /// Every call this driver has been handed, in order. + #[must_use] + pub fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + /// How many calls this driver has been handed. + #[must_use] + pub fn call_count(&self) -> usize { + self.calls.lock().unwrap().len() + } + + /// The single recorded call, panicking when there is not exactly one. + pub fn only_call(&self) -> Call { + let calls = self.calls(); + assert_eq!( + calls.len(), + 1, + "expected exactly one driver call: {calls:?}" + ); + calls.into_iter().next().unwrap() + } +} + + + +/// 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(self.namespace_summaries.lock().unwrap().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(self.recall_result.lock().unwrap().clone()) + } +} + +#[async_trait] +impl MemoryPortability for RecordingProvider { + async fn export_page( + &self, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + self.record(Call::plain("portability.export_page")); + 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), + taint: Some(input.taint), + scoped: None, + }); + Ok("doc".into()) + } + + async fn get_document( + &self, + _namespace: &str, + _key: &str, + ) -> Result, MemoryError> { + self.record(Call::plain("documents.get_document")); + Ok(None) + } + + 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")); + Ok(vec![]) + } + + async fn delete_document( + &self, + _namespace: &str, + _document_id: &str, + ) -> Result { + self.record(Call::plain("documents.delete_document")); + Ok(serde_json::json!({"deleted": false})) + } + + async fn clear_namespace(&self, _namespace: &str) -> Result<(), MemoryError> { + self.record(Call::plain("documents.clear_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(None) + } + + 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, + }); + Ok(()) + } + + async fn kv_delete(&self, _namespace: Option<&str>, _key: &str) -> Result { + self.record(Call::plain("graph.kv_delete")); + Ok(false) + } + + async fn kv_list( + &self, + _namespace: Option<&str>, + _prefix: Option<&str>, + _limit: usize, + ) -> Result, MemoryError> { + self.record(Call::plain("graph.kv_list")); + Ok(vec![]) + } + + 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 { + "recording" + } + + 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< + Option, + 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(self.fast_retrieve_result.lock().unwrap().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(self + .namespace_hits + .lock() + .unwrap() + .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; From 76f19e7f72bae972ea8868f72aef2623351aa8ad Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 9 Sep 2026 14:41:22 +0530 Subject: [PATCH 2/5] feat(conformance): give the full driver storage and hold it to the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, and the second is why the first is worth having. `put_document` / `get_document` / `list_namespaces` / `delete_document` / `clear_namespace` and the four `kv_*` methods now round-trip through maps rather than answering empty. Measured against OpenHuman's suite, 47 tests fail on an empty answer, and they are not asking for much: they seed a row and read it back so the host's own normalization and formatting have something to operate on. The storage is deliberately exact-key only, with no query semantics. That is not a shortcut, it is the line: `MemoryChunks` is read-only on its own family — chunks arrive through ingest, which is engine work — so a test that needs this driver to filter, rank, or summarise is asserting engine behaviour and belongs upstream of the host, not repointed onto a fake. A fake that filtered would let such a test keep passing while testing nothing but the fake. Running the suite against the port immediately found a real contract violation it had inherited: `export_page` answered `Ok(empty)` for a cursor the driver never issued, where the contract requires `Invalid`. A caller paging through would have silently restarted the export and duplicated every row. That is exactly the class of bug `assert_export_cursor_terminates` exists for, and the fake had carried it for as long as it has existed downstream. `.lock().unwrap()` is gone in favour of poison recovery. Downstream this was test code and the lint allowed it; here it is production source under `-D warnings`. `into_inner` is also the better behaviour: a poisoned lock means a test already panicked, and failing its neighbour only obscures which one broke. Refs tinyhumansai/tinymemory#147 --- crates/tinymemory-conformance/src/lib.rs | 1 + .../src/reference/full.rs | 185 ++++++++++++------ .../tests/reference_drivers.rs | 27 +++ 3 files changed, 157 insertions(+), 56 deletions(-) diff --git a/crates/tinymemory-conformance/src/lib.rs b/crates/tinymemory-conformance/src/lib.rs index 9a66e069..b4ef824b 100644 --- a/crates/tinymemory-conformance/src/lib.rs +++ b/crates/tinymemory-conformance/src/lib.rs @@ -41,6 +41,7 @@ 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, diff --git a/crates/tinymemory-conformance/src/reference/full.rs b/crates/tinymemory-conformance/src/reference/full.rs index 154850e5..65005c47 100644 --- a/crates/tinymemory-conformance/src/reference/full.rs +++ b/crates/tinymemory-conformance/src/reference/full.rs @@ -1,10 +1,11 @@ // 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 std::sync::Mutex; +use async_trait::async_trait; use tinymemory_api::capabilities::Capabilities; use tinymemory_api::chunks::Chunk; use tinymemory_api::error::MemoryError; @@ -39,7 +40,22 @@ use tinymemory_api::types::{ NamespaceDocumentInput, NamespaceMemoryHit, NamespaceRetrievalContext, NamespaceSummary, StoredMemoryDocument, }; -use async_trait::async_trait; + +/// 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)] @@ -88,6 +104,11 @@ pub struct RecordingProvider { /// 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 { @@ -106,67 +127,67 @@ impl RecordingProvider { 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 { - *self.recall_result.lock().unwrap() = entries; + *lock(&self.recall_result) = entries; self } /// Sets what [`MemoryRetrieval::fast_retrieve`] returns. #[must_use] pub fn with_fast_retrieve_result(self, response: RetrievalResponse) -> Self { - *self.fast_retrieve_result.lock().unwrap() = response; + *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 { - *self.namespace_hits.lock().unwrap() = hits; + *lock(&self.namespace_hits) = hits; self } /// Sets what [`MemoryCore::namespaces`] returns. #[must_use] pub fn with_namespace_summaries(self, summaries: Vec) -> Self { - *self.namespace_summaries.lock().unwrap() = summaries; + *lock(&self.namespace_summaries) = summaries; self } fn record(&self, call: Call) { - self.calls.lock().unwrap().push(call); + lock(&self.calls).push(call); } /// Every call this driver has been handed, in order. #[must_use] pub fn calls(&self) -> Vec { - self.calls.lock().unwrap().clone() + lock(&self.calls).clone() } /// How many calls this driver has been handed. #[must_use] pub fn call_count(&self) -> usize { - self.calls.lock().unwrap().len() + lock(&self.calls).len() } /// The single recorded call, panicking when there is not exactly one. pub fn only_call(&self) -> Call { - let calls = self.calls(); + let mut calls = self.calls(); assert_eq!( calls.len(), 1, "expected exactly one driver call: {calls:?}" ); - calls.into_iter().next().unwrap() + calls.remove(0) } } - - /// An [`ExportRecord`] fixture. pub fn export_record(taint: MemoryTaint) -> ExportRecord { ExportRecord { @@ -178,8 +199,6 @@ pub fn export_record(taint: MemoryTaint) -> ExportRecord { } } - - /// A [`MemoryEntry`] fixture. pub fn entry(content: &str) -> MemoryEntry { MemoryEntry { @@ -267,7 +286,7 @@ impl MemoryCore for RecordingProvider { async fn namespaces(&self) -> Result, MemoryError> { self.record(Call::plain("core.namespaces")); - Ok(self.namespace_summaries.lock().unwrap().clone()) + Ok(lock(&self.namespace_summaries).clone()) } } @@ -286,18 +305,28 @@ impl MemoryRecall for RecordingProvider { taint: None, scoped: Some(scope.is_some()), }); - Ok(self.recall_result.lock().unwrap().clone()) + 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>, + 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()) } @@ -343,20 +372,41 @@ impl MemoryDocuments for RecordingProvider { async fn put_document(&self, input: NamespaceDocumentInput) -> Result { self.record(Call { method: "documents.put_document".into(), - content: Some(input.content), + content: Some(input.content.clone()), taint: Some(input.taint), scoped: None, }); - Ok("doc".into()) + 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, + namespace: &str, + key: &str, ) -> Result, MemoryError> { self.record(Call::plain("documents.get_document")); - Ok(None) + Ok(lock(&self.documents) + .get(&(namespace.to_string(), key.to_string())) + .cloned()) } async fn list_documents( @@ -369,20 +419,33 @@ impl MemoryDocuments for RecordingProvider { async fn list_namespaces(&self) -> Result, MemoryError> { self.record(Call::plain("documents.list_namespaces")); - Ok(vec![]) + 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, + namespace: &str, + document_id: &str, ) -> Result { self.record(Call::plain("documents.delete_document")); - Ok(serde_json::json!({"deleted": false})) + 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> { + async fn clear_namespace(&self, namespace: &str) -> Result<(), MemoryError> { self.record(Call::plain("documents.clear_namespace")); + lock(&self.documents).retain(|(ns, _), _| ns != namespace); Ok(()) } @@ -500,8 +563,7 @@ impl MemoryTree for RecordingProvider { &self, _per_namespace_cap: usize, _total_cap: usize, - ) -> Result, MemoryError> - { + ) -> Result, MemoryError> { self.record(Call::plain("tree.root_summaries_with_caps")); Ok(Vec::new()) } @@ -615,13 +677,15 @@ impl MemoryGraph for RecordingProvider { _key: &str, ) -> Result, MemoryError> { self.record(Call::plain("graph.kv_get")); - Ok(None) + Ok(lock(&self.kv) + .get(&(_namespace.map(str::to_string), _key.to_string())) + .cloned()) } async fn kv_put( &self, - _namespace: Option<&str>, - _key: &str, + namespace: Option<&str>, + key: &str, value: serde_json::Value, ) -> Result<(), MemoryError> { self.record(Call { @@ -630,12 +694,24 @@ impl MemoryGraph for RecordingProvider { 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 { + async fn kv_delete(&self, namespace: Option<&str>, key: &str) -> Result { self.record(Call::plain("graph.kv_delete")); - Ok(false) + Ok(lock(&self.kv) + .remove(&(namespace.map(str::to_string), key.to_string())) + .is_some()) } async fn kv_list( @@ -645,7 +721,15 @@ impl MemoryGraph for RecordingProvider { _limit: usize, ) -> Result, MemoryError> { self.record(Call::plain("graph.kv_list")); - Ok(vec![]) + 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( @@ -781,8 +865,7 @@ impl MemoryMaintenance for RecordingProvider { async fn degraded_state( &self, - ) -> Result - { + ) -> Result { self.record(Call::plain("maintenance.degraded_state")); Ok(Default::default()) } @@ -791,7 +874,7 @@ impl MemoryMaintenance for RecordingProvider { #[async_trait] impl MemoryProvider for RecordingProvider { fn driver_id(&self) -> &str { - "recording" + FULL_DRIVER_ID } fn capabilities(&self) -> Capabilities { @@ -972,8 +1055,7 @@ impl MemoryEpisodic for RecordingProvider { async fn session_turns( &self, _session_id: &str, - ) -> Result, MemoryError> - { + ) -> Result, MemoryError> { self.record(Call::plain("episodic.session_turns")); Ok(vec![]) } @@ -981,10 +1063,7 @@ impl MemoryEpisodic for RecordingProvider { async fn open_segment( &self, _session_id: &str, - ) -> Result< - Option, - MemoryError, - > { + ) -> Result, MemoryError> { self.record(Call::plain("episodic.open_segment")); Ok(None) } @@ -1166,8 +1245,7 @@ impl MemoryChunks for RecordingProvider { async fn chunk_score( &self, _chunk_id: &str, - ) -> Result, MemoryError> - { + ) -> Result, MemoryError> { self.record(Call::plain("chunks.chunk_score")); Ok(None) } @@ -1175,8 +1253,7 @@ impl MemoryChunks for RecordingProvider { async fn source_ingest_status( &self, _source_prefixes: &[tinymemory_api::provider::chunks::SourceIngestQuery], - ) -> Result, MemoryError> - { + ) -> Result, MemoryError> { self.record(Call::plain("chunks.source_ingest_status")); Ok(vec![]) } @@ -1196,7 +1273,7 @@ impl MemoryRetrieval for RecordingProvider { taint: None, scoped: Some(scope.is_some()), }); - Ok(self.fast_retrieve_result.lock().unwrap().clone()) + Ok(lock(&self.fast_retrieve_result).clone()) } async fn cover_window( @@ -1274,10 +1351,7 @@ impl MemoryRetrieval for RecordingProvider { taint: None, scoped: None, }); - Ok(self - .namespace_hits - .lock() - .unwrap() + Ok(lock(&self.namespace_hits) .iter() .filter(|hit| hit.namespace == namespace) .take(limit) @@ -1454,8 +1528,7 @@ impl MemoryAnswer for RecordingProvider { async fn answer( &self, _request: tinymemory_api::provider::operations::AnswerRequest, - ) -> Result - { + ) -> Result { self.record(Call { method: "answer.answer".into(), content: None, 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()); +} From 8f8bf41dc87be0f755564d7b6481a3be0bbcb889 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 9 Sep 2026 14:44:49 +0530 Subject: [PATCH 3/5] feat(conformance): assert the document tier upholds the upsert rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite covered the entry tier and the KV tier and stopped there. Documents are a third pair — `put_document` / `get_document` keyed on `(namespace, key)` — and nothing checked that it obeys the same replace-on- rewrite rule the entry tier does. A driver that appended instead would show a host two documents where its user wrote one, and the host cannot notice: it asked by key and got a list back. The assertion covers the round trip, field survival including taint, the same-key replace, and `clear_namespace`. It deliberately does not assert `document_id`, `created_at`, `updated_at` or `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. It is gated on `as_documents()`, which is the shape `assert_kv_round_trip` already established — the suite gates on capability rather than asking the caller to declare one, for the same reason it probes for retention instead of taking a flag. So it is contract-binding by construction: `tinymemory-tinycortex`'s `full_provider_conformance` runs `assert_provider` against the real engine, and picks this up with no edit. Verified rather than assumed — that target passes 35/35 with the assertion in place, so the fake and TinyCortex agree on the document tier. Refs tinyhumansai/tinymemory#147 --- crates/tinymemory-conformance/src/lib.rs | 6 +- .../tinymemory-conformance/src/suite/mod.rs | 105 +++++++++++++++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/tinymemory-conformance/src/lib.rs b/crates/tinymemory-conformance/src/lib.rs index b4ef824b..a63a87c1 100644 --- a/crates/tinymemory-conformance/src/lib.rs +++ b/crates/tinymemory-conformance/src/lib.rs @@ -44,9 +44,9 @@ 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/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 From a3006e5d294b391f1ff8f8a547914beed33fa254 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 9 Sep 2026 14:47:02 +0530 Subject: [PATCH 4/5] test(core): cover the deterministic fallback summariser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tree/summarise.rs` was the only module under `tree/` with no test sibling, and `fallback_summary` had no coverage anywhere in this workspace. What covered it was a downstream integration target in OpenHuman, bundled into an assertion that also exercised that host's node-id helpers and a legacy markdown parse — so the behaviour was pinned, but in a repository that does not own it and inside a test about something else. It is worth owning here because it is not a corner: `fallback_summary` is the answer when no chat provider is reachable, which is the path a degraded install actually runs. Two cases. The first pins that a blank input is dropped rather than summarised into an empty bullet, and that the token budget is honoured. It also carries an entity on the dropped input and a topic on the surviving one, which pins that the fallback propagates neither — the failure that matters is the other direction, where a dropped input's entities are attributed to a summary whose text never mentions them. The second pins that no inputs produce no summary rather than a bullet with nothing behind it; that case had no coverage on either side. Deliberately not ported: the `SummaryContext` half of the downstream assertion, which sets four fields and reads two of them back. That is a coverage-shaped statement about struct literals, not about behaviour. Refs tinyhumansai/tinymemory#147 --- crates/tinymemory-core/src/tree/mod.rs | 2 + .../src/tree/summarise_tests.rs | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/tinymemory-core/src/tree/summarise_tests.rs 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); +} From 3ef78ae4c30b006f462795921211bd572c299343 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 9 Sep 2026 15:19:16 +0530 Subject: [PATCH 5/5] ci: exclude the host test double from the production-coverage floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reference/full.rs` lands at 23% line coverage and cannot honestly be raised here. It is a driver that exists to be bound by *other repositories'* test suites — it is what lets a host exercise its own layer above the contract without linking an engine — so this workspace has no caller for most of it. Covering it would mean inventing suite assertions for every optional family, and that is not a free move: an assertion added to `assert_provider` is picked up automatically by `full_provider_conformance`, so it becomes a requirement on TinyCortex. Writing engine requirements to satisfy a coverage number is a worse outcome than an excluded double. `reference/mod.rs` is deliberately left in. The suite drives it hard and it sits at ~90%, which is what a driver this workspace does own should look like — and keeping it measured is what stops this exclusion from quietly widening to "drivers do not count". The floor reads 85.83% with the exclusion, against 84.65% while the double was dragging on it. Same code, more honest number. --- .github/workflows/ci.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) 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"