Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ is deliberately outside `full`: "give me the whole workspace" is not the same
request as "give me the test doubles".

This table says which crate each feature brings in. For what each *engine*
feature actually serves — driver class, and how many of the eighteen capability
feature actually serves — driver class, and how many of the twenty capability
families answer — see the engine table under
[Using from your project](#using-from-your-project).

Expand Down Expand Up @@ -173,7 +173,7 @@ let provider = Arc::new(provider(Arc::new(InMemoryMemoryStore::new())));
```

That is a complete embedded setup for the mandatory three families. The full
eighteen-family engine (`TinycortexProvider`) additionally needs the host
twenty-family engine (`TinycortexProvider`) additionally needs the host
seams (`EmbeddingHost` et al.) installed — see
`crates/tinymemory-tinycortex/tests/full_provider_conformance.rs` for the
minimal working wiring.
Expand All @@ -200,10 +200,10 @@ for assistant-memory workloads; wrong for high-volume keyed storage.
## The contract

`MemoryProvider` is an object-safe trait with **three mandatory** capability
families and **fifteen optional** ones. The mandatory three are supertraits, so a
driver missing any of them cannot be constructed; the optional fifteen are reached
through `as_ingest()` / `as_tree()` / … accessors that default to `None`, so a
minimal driver implements what it supports and inherits correct absence for
families and **seventeen optional** ones. The mandatory three are supertraits, so
a driver missing any of them cannot be constructed; the optional seventeen are
reached through `as_ingest()` / `as_tree()` / … accessors that default to `None`,
so a minimal driver implements what it supports and inherits correct absence for
everything else.

A driver's advertised set and its reachable accessors must agree.
Expand Down
2 changes: 2 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ doc-valid-idents = [
# Product names in `chunks::SourceKind`'s prose, not Rust items.
"WhatsApp",
"FastMail",
# Toolkit names in `composio`'s prose, likewise.
"ClickUp",
]
21 changes: 17 additions & 4 deletions crates/tinymemory-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,26 @@
//! ## Module map
//!
//! - [`types`]: pure data contracts (entries, hits, taint, namespaces).
//! - [`evidence`]: [`evidence::EvidenceRef`], the pointer a learned fact keeps
//! back to what it was learned from. Also re-exported as
//! [`host::EvidenceRef`], which is where the memory store's callers name it.
//! - [`learning`]: the learning-candidate taxonomy
//! ([`learning::FacetClass`], [`learning::CueFamily`],
//! [`learning::LearningCandidate`]) — what a producer asserts about the user
//! and how strongly, with the buffer that queues it left in the engine crate.
//! - [`composio`]: the connector-sync vocabulary — [`composio::SyncOutcome`],
//! [`composio::NormalizedTask`], [`composio::SyncState`],
//! [`composio::ToolScope`] and friends. **Not** [`host::composio`], which is
//! the *client* seam: connections, execute responses and the capability
//! matrix a host serves to the memory layer. This one is what a provider run
//! produces and remembers; that one is how it reaches Composio at all.
//! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived
//! [`recall::OwnedRecallOpts`] recall filters (both re-exported from
//! [`types`]).
//! - [`capabilities`]: the eighteen [`capabilities::Capability`] families and
//! - [`capabilities`]: the twenty [`capabilities::Capability`] families and
//! the [`capabilities::Capabilities`] set negotiated at bind time.
//! - [`provider`]: the driver contract — [`provider::MemoryProvider`] plus the
//! eighteen capability family traits and the value types they need.
//! twenty capability family traits and the value types they need.
//! - [`null`]: [`null::NullMemoryProvider`], the reference driver a
//! compiled-out or unconfigured memory subsystem binds to.
//! - [`health`]: [`health::MemoryHealth`], the liveness state a driver reports.
Expand Down Expand Up @@ -108,8 +121,8 @@ pub mod sync_events;
// point: a second definition would need a conversion at the module seam that
// nothing type-checks.
pub use tinymemory_bus::{
capabilities, chunks, error, goals, graph, health, namespace, recall, tool_memory, tree, types,
version, wire,
capabilities, chunks, composio, error, evidence, goals, graph, health, learning, namespace,
recall, tool_memory, tree, types, version, wire,
};
/// The mandatory-family composition: wrap any [`traits::Memory`] backend as a
/// complete [`provider::MemoryProvider`].
Expand Down
98 changes: 89 additions & 9 deletions crates/tinymemory-api/src/null.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! `stub.rs` files with one generic answer.
//!
//! It is also the fixture the capability-degradation tests bind: with it in the
//! slot, the fifteen optional families are unadvertised, so their RPC methods are
//! slot, the optional families are unadvertised, so their RPC methods are
//! unregistered and their agent tools are absent — and the core still boots.
//!
//! And it is the existence proof for the mandatory set: if
Expand All @@ -32,9 +32,9 @@
//! driver that failed to bind — **that** case falls back to the embedded
//! default, never to this. Do not wire it as a general-purpose failure mode.
//!
//! ## Why it implements all eighteen families but advertises three
//! ## Why it implements the optional families but advertises three
//!
//! The fifteen optional families are implemented and every method returns
//! The optional families are implemented and every method returns
//! [`crate::error::MemoryError::Unsupported`] naming its family, but the
//! `as_*` accessors return `None` and
//! [`crate::provider::MemoryProvider::capabilities`] lists only the mandatory
Expand All @@ -60,13 +60,15 @@ use crate::provider::types::{
IngestOutcome, MaintenanceReport, ResetOutcome, SnapshotRef, SourceItem, SourceScope,
};
use crate::provider::{
AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, CoverWindowQuery, EntityMatch,
FacetType, FastRetrieveQuery, MemoryChunks, MemoryCore, MemoryDiff, MemoryDocuments,
AddressBookSeedOutcome, ChunkDetail, ChunkEmbedding, ChunkQuery, CodingSessionIngestReport,
CodingSessionIngestRequest, CodingSessionSource, CoverWindowQuery, EntityMatch, FacetType,
FastRetrieveQuery, MemoryChunks, MemoryCodingSessions, MemoryCore, MemoryDiff, MemoryDocuments,
MemoryEntities, MemoryGoals, MemoryGraph, MemoryIngest, MemoryMaintenance, MemoryPeople,
MemoryPortability, MemoryProfile, MemoryProvider, MemoryRecall, MemoryRetrieval,
MemorySourceSink, MemoryToolMemory, MemoryTree, PersonHandle, PersonInteraction, PersonRecord,
PersonScore, ProfileFacet, RankedPerson, ResolvedPerson, RetrievalHit, RetrievalResponse,
SourceRetrievalQuery, UserState,
MemorySourceSink, MemorySourceSync, MemoryToolMemory, MemoryTree, PersonHandle,
PersonInteraction, PersonRecord, PersonScore, ProfileFacet, RankedPerson, RawArchiveCoverage,
RawRebuildOutcome, ResolvedPerson, RetrievalHit, RetrievalResponse, SourceRetrievalQuery,
SourceSyncState, SourceSyncStatus, SyncAuditEntry, SyncRunOutcome, UserState,
};
use crate::recall::OwnedRecallOpts;
use crate::tool_memory::ToolMemoryRule;
Expand Down Expand Up @@ -106,7 +108,7 @@ impl MemoryProvider for NullMemoryProvider {
NULL_DRIVER_ID
}

/// Exactly the mandatory three. The fifteen optional families are implemented
/// Exactly the mandatory three. The optional families are implemented
/// below but deliberately not advertised, so they stay unreachable through
/// the trait object.
fn capabilities(&self) -> Capabilities {
Expand Down Expand Up @@ -705,6 +707,84 @@ impl MemoryProfile for NullMemoryProvider {
}
}

#[async_trait]
impl MemorySourceSync for NullMemoryProvider {
async fn run_connection_sync(
&self,
_toolkit: &str,
_connection_id: &str,
) -> Result<SyncRunOutcome, MemoryError> {
unsupported(Capability::SourceSync)
}

async fn source_sync_state(
&self,
_toolkit: &str,
_connection_id: &str,
) -> Result<Option<SourceSyncState>, MemoryError> {
// Not `Ok(None)`, which the trait defines as "this connection has never
// synced". This driver cannot sync at all, and answering "never synced"
// would put a connection with a plausible empty state in front of a
// caller that would then offer to sync it.
unsupported(Capability::SourceSync)
}

async fn sync_audit_log(
&self,
_limit: Option<usize>,
) -> Result<Vec<SyncAuditEntry>, MemoryError> {
unsupported(Capability::SourceSync)
}

async fn estimate_sync_cost_usd(
&self,
_input_tokens: u64,
_output_tokens: u64,
) -> Result<f64, MemoryError> {
// The trait lets a driver whose sync is free answer `0.0`. This one has
// no sync to price, and quoting a free one would be a price rather than
// an absence — the same distinction the state read above draws.
unsupported(Capability::SourceSync)
}

async fn sync_statuses(&self) -> Result<Vec<SourceSyncStatus>, MemoryError> {
unsupported(Capability::SourceSync)
}

async fn raw_archive_coverage(
&self,
_tree_scope: &str,
_archive_source_id: &str,
) -> Result<RawArchiveCoverage, MemoryError> {
unsupported(Capability::SourceSync)
}

async fn rebuild_from_raw_archive(
&self,
_tree_scope: &str,
_archive_source_id: &str,
) -> Result<RawRebuildOutcome, MemoryError> {
unsupported(Capability::SourceSync)
}
}

#[async_trait]
impl MemoryCodingSessions for NullMemoryProvider {
async fn coding_session_status(&self) -> Result<Vec<CodingSessionSource>, MemoryError> {
// Not an empty list. The trait defines one row per agent the driver
// knows about, so an empty answer is "I looked and found no agents
// installed" — which this driver did not do.
unsupported(Capability::CodingSessions)
}

async fn ingest_coding_sessions(
&self,
_request: CodingSessionIngestRequest,
) -> Result<CodingSessionIngestReport, MemoryError> {
unsupported(Capability::CodingSessions)
}
}

#[cfg(test)]
#[path = "null_tests.rs"]
mod tests;
56 changes: 54 additions & 2 deletions crates/tinymemory-api/src/null_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ fn every_unadvertised_family_is_unreachable_through_the_trait_object() {
assert!(provider.as_tool_memory().is_none());
assert!(provider.as_sources().is_none());
assert!(provider.as_maintenance().is_none());
assert!(provider.as_source_sync().is_none());
assert!(provider.as_coding_sessions().is_none());
}

#[test]
Expand Down Expand Up @@ -231,8 +233,8 @@ fn every_optional_method_fails_with_its_advertised_family_name() {
use crate::goals::GoalsDoc;
use crate::provider::types::{IngestItem, SourceItem};
use crate::provider::{
ChunkQuery, CoverWindowQuery, FacetType, FastRetrieveQuery, PersonHandle,
PersonInteraction, SourceRetrievalQuery, UserState,
ChunkQuery, CodingSessionIngestRequest, CoverWindowQuery, FacetType, FastRetrieveQuery,
PersonHandle, PersonInteraction, SourceRetrievalQuery, UserState,
};
use crate::tool_memory::{ToolMemoryPriority, ToolMemoryRule, ToolMemorySource};
use crate::tree::IngestRequest;
Expand Down Expand Up @@ -507,6 +509,56 @@ fn every_optional_method_fails_with_its_advertised_family_name() {
);
assert_unsupported(block_on(driver.drop_facets_below(0.5)), Capability::Profile);
assert!(!block_on(driver.workflow_identity_matches("*", "value")));

assert_unsupported(
block_on(driver.run_connection_sync("gmail", "conn-1")),
Capability::SourceSync,
);
assert_unsupported(
block_on(driver.source_sync_state("gmail", "conn-1")),
Capability::SourceSync,
);
assert_unsupported(
block_on(driver.sync_audit_log(None)),
Capability::SourceSync,
);
assert_unsupported(
block_on(driver.estimate_sync_cost_usd(1_000, 100)),
Capability::SourceSync,
);
assert_unsupported(block_on(driver.sync_statuses()), Capability::SourceSync);
assert_unsupported(
block_on(driver.raw_archive_coverage("gmail:conn-1", "archive")),
Capability::SourceSync,
);
assert_unsupported(
block_on(driver.rebuild_from_raw_archive("gmail:conn-1", "archive")),
Capability::SourceSync,
);

assert_unsupported(
block_on(driver.coding_session_status()),
Capability::CodingSessions,
);
assert_unsupported(
block_on(driver.ingest_coding_sessions(CodingSessionIngestRequest::default())),
Capability::CodingSessions,
);
}

#[test]
fn the_two_members_added_to_existing_families_refuse_rather_than_report_nothing() {
// Both inherit their trait's default body, and both defaults are a refusal
// on purpose. A `flush_source_tree` answering `Ok(0)` would tell a user
// their source was flushed and had nothing to write; a `diagnose`
// answering an empty report would have to claim `healthy` one way or the
// other, and both claims are untrue of a driver that never looked.
let driver = NullMemoryProvider::new();
assert_unsupported(
block_on(driver.flush_source_tree("gmail:conn-1")),
Capability::Tree,
);
assert_unsupported(block_on(driver.diagnose()), Capability::Maintenance);
}

#[test]
Expand Down
9 changes: 7 additions & 2 deletions crates/tinymemory-api/src/provider/audit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,18 @@ fn honest_driver_passes_the_audit() {
#[test]
fn over_claiming_driver_is_reported_as_advertised_but_absent() {
// Advertises everything, exposes no optional accessor. Every one of the
// thirteen optional families would fail on first call — the exact
// optional families would fail on first call — the exact
// registered-but-failing outcome the capability filter exists to prevent.
let liar = Fixture::new(Capabilities::all(), false);

let audit = audit_provider(&liar).expect_err("over-claiming driver must fail the audit");
assert_eq!(audit.present_but_unadvertised, Vec::new());
assert_eq!(audit.advertised_but_absent.len(), 15);
// Everything except the mandatory three, derived rather than spelled out:
// a family added to the contract must land here without editing a literal.
assert_eq!(
audit.advertised_but_absent.len(),
Capability::ALL.len() - Capability::MANDATORY.len()
);
assert!(audit.advertised_but_absent.contains(&Capability::Tree));
// The mandatory three are supertraits, so they can never be missing.
assert!(!audit.advertised_but_absent.contains(&Capability::Core));
Expand Down
47 changes: 47 additions & 0 deletions crates/tinymemory-api/src/provider/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,4 +363,51 @@ pub trait MemoryTree: Send + Sync {
) -> Result<Vec<TreeLeaf>, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}

/// Seal and cascade one source's tree now, and report how many summaries
/// were written.
///
/// The "flush this source" control, for a user who does not want to wait
/// for the scheduled window. Everything else in this family is addressed
/// by *namespace*; this one is addressed by **source scope** — the
/// `{platform}:{connection}` string a sync writes under — because that is
/// the identity a caller has when it is looking at one connected source.
///
/// # Why not `seal` plus `cascade` on the same namespace
///
/// Because a source scope is not a namespace, and the mapping between them
/// is the driver's. A source's content may sit under a tree the driver
/// created for it, named however the driver names trees; a caller that
/// tried to derive the namespace would be reimplementing that naming, and
/// would get it wrong for exactly the sources whose trees were created
/// before whatever convention it copied.
///
/// It is also one operation rather than two on purpose. Sealing without
/// cascading leaves a tier of leaves with no summary above them, which
/// reads as an empty tree to every structural query — and a caller that
/// made the second call separately would have a window where that is the
/// state.
///
/// # Why a count and not a tree
///
/// The engine's own flush hands back a live tree object, and the caller's
/// question is "did anything happen". A handle to a driver's internal
/// object is precisely what this contract exists not to pass, and once the
/// labelling decision that flush needs is made driver-side — which is
/// where it comes from anyway — there is nothing else the object was
/// carrying that a caller can use.
///
/// # Errors
///
/// [`MemoryError::Unsupported`] from a driver with a tree family but no
/// source-scoped flush. Backend failures otherwise.
///
/// A scope with nothing buffered is `Ok(0)`, not an error: idempotent for
/// the same reason [`Self::seal`] is, so a caller may offer the control
/// unconditionally. An **unknown** scope is also `Ok(0)` — the driver
/// creates the tree if it has to, so there is no scope it can refuse, and
/// a caller cannot use this to probe which scopes exist.
async fn flush_source_tree(&self, _source_scope: &str) -> Result<u64, MemoryError> {
Err(MemoryError::unsupported(Capability::Tree))
}
}
Loading
Loading